mqtt-plus 1.4.7 → 1.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/dst-stage1/mqtt-plus-auth.js +18 -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-codec.js +4 -0
- package/dst-stage1/mqtt-plus-encode.d.ts +2 -0
- package/dst-stage1/mqtt-plus-encode.js +5 -2
- package/dst-stage1/mqtt-plus-error.js +2 -2
- package/dst-stage1/mqtt-plus-event.js +15 -14
- package/dst-stage1/mqtt-plus-meta.js +4 -3
- package/dst-stage1/mqtt-plus-service.js +27 -21
- package/dst-stage1/mqtt-plus-sink.js +50 -27
- package/dst-stage1/mqtt-plus-source.js +32 -20
- package/dst-stage1/mqtt-plus-subscription.d.ts +2 -0
- package/dst-stage1/mqtt-plus-subscription.js +73 -38
- package/dst-stage1/mqtt-plus-trace.js +3 -3
- package/dst-stage1/mqtt-plus-util.js +7 -5
- package/dst-stage1/mqtt-plus-version.js +1 -1
- package/dst-stage2/mqtt-plus.cjs.js +233 -153
- package/dst-stage2/mqtt-plus.esm.js +233 -153
- package/dst-stage2/mqtt-plus.umd.js +12 -12
- package/package.json +1 -1
- package/src/mqtt-plus-auth.ts +19 -5
- package/src/mqtt-plus-base.ts +21 -7
- package/src/mqtt-plus-codec.ts +4 -0
- package/src/mqtt-plus-encode.ts +6 -2
- package/src/mqtt-plus-error.ts +2 -2
- package/src/mqtt-plus-event.ts +15 -16
- package/src/mqtt-plus-meta.ts +7 -6
- package/src/mqtt-plus-service.ts +31 -25
- package/src/mqtt-plus-sink.ts +55 -31
- package/src/mqtt-plus-source.ts +36 -24
- package/src/mqtt-plus-subscription.ts +80 -38
- package/src/mqtt-plus-trace.ts +11 -11
- package/src/mqtt-plus-util.ts +8 -5
- package/src/mqtt-plus-version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,32 @@
|
|
|
2
2
|
ChangeLog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
-
1.
|
|
5
|
+
1.4.9 (2026-02-22)
|
|
6
|
+
------------------
|
|
7
|
+
|
|
8
|
+
- BUGFIX: clear internal response handlers in destroy()
|
|
9
|
+
- BUGFIX: correctly decrement counter in subscription handling
|
|
10
|
+
- BUGFIX: let the registration's destroy() throw errors correctly
|
|
11
|
+
- BUGFIX: correctly handle synchronous response handler failures
|
|
12
|
+
- BUGFIX: fix internal chunkToBuffer() method for byte-length calculation
|
|
13
|
+
- BUGFIX: apply the same limits on sender size for authenticate() as on receiver side
|
|
14
|
+
- BUGFIX: check for name/topic mismatches also in source fetch()
|
|
15
|
+
- REFACTOR: factor out topic subscription and spooling topic unsubscription into helper function
|
|
16
|
+
- REFACTOR: make response handlers async functions to correctly catch their failures
|
|
17
|
+
- IMPROVEMENT: use a cached TextEncoder in utility functions
|
|
18
|
+
- IMPROVEMENT: ensure generated NanoIDs do not conflict with pending requests
|
|
19
|
+
|
|
20
|
+
1.4.8 (2026-02-22)
|
|
21
|
+
------------------
|
|
22
|
+
|
|
23
|
+
- PERFORMANCE: cache encoder/decoder in encoding functions
|
|
24
|
+
- BUGFIX: fix memory leak in destroy() for sink
|
|
25
|
+
- BUGFIX: namespace timers of sink() and source() to avoid conflicts
|
|
26
|
+
- BUGFIX: align event() share default with service/source/sink
|
|
27
|
+
- CLEANUP: refactor RefCountedSubscription class to be redundancy-free
|
|
28
|
+
- CLEANUP: various minor code cleanups (formatting, modernization)
|
|
29
|
+
|
|
30
|
+
1.4.7 (2026-02-22)
|
|
6
31
|
------------------
|
|
7
32
|
|
|
8
33
|
- IMPROVEMENT: provide a global "share" option
|
|
@@ -27,6 +27,8 @@ import { jwtVerify } from "jose/jwt/verify";
|
|
|
27
27
|
import * as pbkdf2 from "@stablelib/pbkdf2";
|
|
28
28
|
import * as sha256 from "@stablelib/sha256";
|
|
29
29
|
import { MetaTrait } from "./mqtt-plus-meta";
|
|
30
|
+
/* reusable encoder instance */
|
|
31
|
+
const textEncoder = new TextEncoder();
|
|
30
32
|
/* authentication trait */
|
|
31
33
|
export class AuthTrait extends MetaTrait {
|
|
32
34
|
constructor() {
|
|
@@ -41,26 +43,37 @@ export class AuthTrait extends MetaTrait {
|
|
|
41
43
|
if (credential.length === 0)
|
|
42
44
|
throw new Error("credential must not be empty");
|
|
43
45
|
/* use a derived key with minimum length of 32 for JWT HS256 */
|
|
44
|
-
const pass =
|
|
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 */
|
|
49
51
|
async issue(payload) {
|
|
50
52
|
if (this._credential === null)
|
|
51
53
|
throw new Error("credential has to be provided before issuing tokens");
|
|
54
|
+
if (payload.roles.length === 0)
|
|
55
|
+
throw new Error("payload.roles must be a non-empty array");
|
|
56
|
+
if (payload.roles.length > 64)
|
|
57
|
+
throw new Error("payload.roles must not exceed 64 roles");
|
|
52
58
|
const jwt = new SignJWT(payload);
|
|
53
59
|
jwt.setProtectedHeader({ alg: "HS256", typ: "JWT" });
|
|
54
60
|
const token = await jwt.sign(this._credential);
|
|
55
61
|
return token;
|
|
56
62
|
}
|
|
57
63
|
authenticate(token, remove) {
|
|
58
|
-
if (token === undefined)
|
|
59
|
-
|
|
64
|
+
if (token === undefined) {
|
|
65
|
+
const tokens = Array.from(this._tokens).filter((token) => token.length <= 8192).slice(0, 8);
|
|
66
|
+
return tokens.length > 0 ? tokens : undefined;
|
|
67
|
+
}
|
|
60
68
|
else if (remove === true)
|
|
61
69
|
this._tokens.delete(token);
|
|
62
|
-
else
|
|
70
|
+
else {
|
|
71
|
+
if (token.length > 8192)
|
|
72
|
+
throw new Error("token must not exceed 8192 characters");
|
|
73
|
+
if (!this._tokens.has(token) && this._tokens.size >= 8)
|
|
74
|
+
throw new Error("at most 8 tokens can be authenticated at once");
|
|
63
75
|
this._tokens.add(token);
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
/* validate client-side token on server-side */
|
|
66
79
|
async validateToken(token) {
|
|
@@ -2,7 +2,7 @@ import { MqttClient, type IClientSubscribeOptions, type IClientPublishOptions }
|
|
|
2
2
|
import type { APISchema, Registration } from "./mqtt-plus-api";
|
|
3
3
|
import type { APIOptions } from "./mqtt-plus-options";
|
|
4
4
|
import { TraceTrait } from "./mqtt-plus-trace";
|
|
5
|
-
import
|
|
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
|
}
|
|
@@ -86,6 +86,8 @@ class Codec {
|
|
|
86
86
|
if (this.format === "cbor") {
|
|
87
87
|
if (!(data instanceof Uint8Array))
|
|
88
88
|
throw new Error("failed to decode CBOR format (data type is not Uint8Array)");
|
|
89
|
+
if (data.byteLength === 0)
|
|
90
|
+
throw new Error("failed to decode CBOR format (data is empty)");
|
|
89
91
|
try {
|
|
90
92
|
result = CBOR.decode(data, { tags: this.tags });
|
|
91
93
|
}
|
|
@@ -96,6 +98,8 @@ class Codec {
|
|
|
96
98
|
else if (this.format === "json") {
|
|
97
99
|
if (typeof data !== "string")
|
|
98
100
|
throw new Error("failed to decode JSON format (data type is not string)");
|
|
101
|
+
if (data.length === 0)
|
|
102
|
+
throw new Error("failed to decode JSON format (data is empty)");
|
|
99
103
|
try {
|
|
100
104
|
result = JSONX.parse(data);
|
|
101
105
|
}
|
|
@@ -2,6 +2,8 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import type { APISchema } from "./mqtt-plus-api";
|
|
3
3
|
import { CodecTrait } from "./mqtt-plus-codec";
|
|
4
4
|
export declare class EncodeTrait<T extends APISchema = APISchema> extends CodecTrait<T> {
|
|
5
|
+
private static encoder;
|
|
6
|
+
private static decoder;
|
|
5
7
|
str2buf(data: string): Uint8Array;
|
|
6
8
|
buf2str(data: Uint8Array): string;
|
|
7
9
|
arr2buf(data: Buffer | Uint8Array | Int8Array): Uint8Array;
|
|
@@ -26,13 +26,16 @@ import { Buffer } from "node:buffer";
|
|
|
26
26
|
import { CodecTrait } from "./mqtt-plus-codec";
|
|
27
27
|
/* encoding trait */
|
|
28
28
|
export class EncodeTrait extends CodecTrait {
|
|
29
|
+
/* reusable encoder/decoder instances */
|
|
30
|
+
static { this.encoder = new TextEncoder(); }
|
|
31
|
+
static { this.decoder = new TextDecoder(); }
|
|
29
32
|
/* convert character string to buffer */
|
|
30
33
|
str2buf(data) {
|
|
31
|
-
return
|
|
34
|
+
return EncodeTrait.encoder.encode(data);
|
|
32
35
|
}
|
|
33
36
|
/* convert buffer to character string */
|
|
34
37
|
buf2str(data) {
|
|
35
|
-
return
|
|
38
|
+
return EncodeTrait.decoder.decode(data);
|
|
36
39
|
}
|
|
37
40
|
/* convert byte-based typed array to buffer */
|
|
38
41
|
arr2buf(data) {
|
|
@@ -167,7 +167,7 @@ export function run(...args) {
|
|
|
167
167
|
}
|
|
168
168
|
else if (typeof args[0] === "string") {
|
|
169
169
|
description = args[0];
|
|
170
|
-
if (args[1] instanceof Spool) {
|
|
170
|
+
if (args[1] instanceof Spool || (args[1] === undefined && typeof args[2] === "function")) {
|
|
171
171
|
spool = args[1];
|
|
172
172
|
action = args[2];
|
|
173
173
|
oncatch = args[3];
|
|
@@ -182,7 +182,7 @@ export function run(...args) {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
else {
|
|
185
|
-
if (args[0] instanceof Spool) {
|
|
185
|
+
if (args[0] instanceof Spool || (args[0] === undefined && typeof args[1] === "function")) {
|
|
186
186
|
spool = args[0];
|
|
187
187
|
action = args[1];
|
|
188
188
|
oncatch = args[2];
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { nanoid } from "nanoid";
|
|
25
25
|
import { AuthTrait } from "./mqtt-plus-auth";
|
|
26
|
-
import {
|
|
26
|
+
import { Spool, ensureError } from "./mqtt-plus-error";
|
|
27
27
|
/* Event Emission Trait */
|
|
28
28
|
export class EventTrait extends AuthTrait {
|
|
29
29
|
async event(nameOrConfig, ...args) {
|
|
@@ -31,14 +31,14 @@ export class EventTrait extends AuthTrait {
|
|
|
31
31
|
let name;
|
|
32
32
|
let callback;
|
|
33
33
|
let options = {};
|
|
34
|
-
let share;
|
|
34
|
+
let share = this.options.share;
|
|
35
35
|
let auth;
|
|
36
36
|
if (typeof nameOrConfig === "object" && nameOrConfig !== null) {
|
|
37
37
|
/* object-based API */
|
|
38
38
|
name = nameOrConfig.name;
|
|
39
39
|
callback = nameOrConfig.callback;
|
|
40
40
|
options = nameOrConfig.options ?? {};
|
|
41
|
-
share = nameOrConfig.share;
|
|
41
|
+
share = nameOrConfig.share ?? this.options.share;
|
|
42
42
|
auth = nameOrConfig.auth;
|
|
43
43
|
}
|
|
44
44
|
else {
|
|
@@ -52,40 +52,41 @@ export class EventTrait extends AuthTrait {
|
|
|
52
52
|
if (this.onRequest.has(`event-emission:${name}`))
|
|
53
53
|
throw new Error(`event: event "${name}" already registered`);
|
|
54
54
|
/* generate the corresponding MQTT topics for broadcast and direct use */
|
|
55
|
-
const topicS = share ? `$share/${share}/${name}` : name;
|
|
55
|
+
const topicS = share !== "" ? `$share/${share}/${name}` : name;
|
|
56
56
|
const topicB = this.options.topicMake(topicS, "event-emission");
|
|
57
57
|
const topicD = this.options.topicMake(name, "event-emission", this.options.id);
|
|
58
58
|
/* remember the registration */
|
|
59
|
-
this.onRequest.set(`event-emission:${name}`, (request, topicName) => {
|
|
59
|
+
this.onRequest.set(`event-emission:${name}`, async (request, topicName) => {
|
|
60
60
|
/* determine event information */
|
|
61
61
|
const senderId = request.sender;
|
|
62
|
+
if (senderId === undefined || senderId === "")
|
|
63
|
+
throw new Error("invalid request: missing sender");
|
|
62
64
|
const params = request.params ?? [];
|
|
63
65
|
/* create information object */
|
|
64
|
-
const info = { sender: senderId
|
|
66
|
+
const info = { sender: senderId };
|
|
65
67
|
if (request.receiver)
|
|
66
68
|
info.receiver = request.receiver;
|
|
67
69
|
if (request.meta)
|
|
68
70
|
info.meta = request.meta;
|
|
69
71
|
/* asynchronously execute handler */
|
|
70
|
-
|
|
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
|
}
|
|
@@ -29,10 +29,11 @@ export class MetaTrait extends TimerTrait {
|
|
|
29
29
|
/* internal state */
|
|
30
30
|
this._meta = new Map();
|
|
31
31
|
}
|
|
32
|
-
meta(
|
|
33
|
-
|
|
32
|
+
meta(...args) {
|
|
33
|
+
const [key, value] = args;
|
|
34
|
+
if (args.length === 0)
|
|
34
35
|
return Object.fromEntries(this._meta);
|
|
35
|
-
else if (
|
|
36
|
+
else if (args.length === 1)
|
|
36
37
|
return this._meta.get(key);
|
|
37
38
|
else if (value === undefined || value === null)
|
|
38
39
|
this._meta.delete(key);
|
|
@@ -57,7 +57,7 @@ export class ServiceTrait extends EventTrait {
|
|
|
57
57
|
const topicB = this.options.topicMake(topicS, "service-call-request");
|
|
58
58
|
const topicD = this.options.topicMake(name, "service-call-request", this.options.id);
|
|
59
59
|
/* remember the registration */
|
|
60
|
-
this.onRequest.set(`service-call-request:${name}`, (request, topicName) => {
|
|
60
|
+
this.onRequest.set(`service-call-request:${name}`, async (request, topicName) => {
|
|
61
61
|
/* determine request information */
|
|
62
62
|
const requestId = request.id;
|
|
63
63
|
const senderId = request.sender;
|
|
@@ -70,38 +70,42 @@ export class ServiceTrait extends EventTrait {
|
|
|
70
70
|
info.receiver = request.receiver;
|
|
71
71
|
if (request.meta)
|
|
72
72
|
info.meta = request.meta;
|
|
73
|
-
/*
|
|
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), `handler for service "${name}" failed`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
98
104
|
});
|
|
99
105
|
spool.roll(() => { this.onRequest.delete(`service-call-request:${name}`); });
|
|
100
106
|
/* subscribe to MQTT topics */
|
|
101
|
-
await
|
|
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 }));
|
|
@@ -40,6 +40,8 @@ export class SinkTrait extends SourceTrait {
|
|
|
40
40
|
async destroy() {
|
|
41
41
|
for (const stream of this.pushStreams.values())
|
|
42
42
|
stream.destroy();
|
|
43
|
+
for (const spool of this.pushSpools.values())
|
|
44
|
+
await spool.unroll();
|
|
43
45
|
this.pushStreams.clear();
|
|
44
46
|
this.pushSpools.clear();
|
|
45
47
|
await super.destroy();
|
|
@@ -75,7 +77,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
75
77
|
const topicReqD = this.options.topicMake(name, "sink-push-request", this.options.id);
|
|
76
78
|
const topicChunkD = this.options.topicMake(name, "sink-push-chunk", this.options.id);
|
|
77
79
|
/* remember the registration */
|
|
78
|
-
this.onRequest.set(`sink-push-request:${name}`, (request, topicName) => {
|
|
80
|
+
this.onRequest.set(`sink-push-request:${name}`, async (request, topicName) => {
|
|
79
81
|
/* determine information */
|
|
80
82
|
const requestId = request.id;
|
|
81
83
|
const params = request.params ?? [];
|
|
@@ -98,7 +100,8 @@ export class SinkTrait extends SourceTrait {
|
|
|
98
100
|
this.pushSpools.set(requestId, reqSpool);
|
|
99
101
|
reqSpool.roll(() => { this.pushSpools.delete(requestId); });
|
|
100
102
|
/* check authentication and prepare stream */
|
|
101
|
-
|
|
103
|
+
let ackSent = false;
|
|
104
|
+
try {
|
|
102
105
|
if (topicName !== request.name)
|
|
103
106
|
throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`);
|
|
104
107
|
let authenticated = undefined;
|
|
@@ -112,14 +115,15 @@ export class SinkTrait extends SourceTrait {
|
|
|
112
115
|
creditGranted: chunkCredit
|
|
113
116
|
} : undefined;
|
|
114
117
|
/* utility functions for timeout management */
|
|
115
|
-
const
|
|
118
|
+
const pushTimerId = `sink-push-recv:${requestId}`;
|
|
119
|
+
const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
|
|
116
120
|
const stream = this.pushStreams.get(requestId);
|
|
117
121
|
if (stream !== undefined)
|
|
118
122
|
stream.destroy(new Error("push stream timeout"));
|
|
119
123
|
const spool = this.pushSpools.get(requestId);
|
|
120
124
|
spool?.unroll();
|
|
121
125
|
});
|
|
122
|
-
const clearPushTimeout = () => this.timerClear(
|
|
126
|
+
const clearPushTimeout = () => this.timerClear(pushTimerId);
|
|
123
127
|
/* create a readable for buffering received chunks */
|
|
124
128
|
const readable = new Readable({
|
|
125
129
|
highWaterMark: chunkCredit > 0 ? chunkCredit * this.options.chunkSize : 16 * 1024,
|
|
@@ -145,8 +149,12 @@ export class SinkTrait extends SourceTrait {
|
|
|
145
149
|
readable.once("error", () => reqSpool.unroll());
|
|
146
150
|
/* register chunk dispatch callback */
|
|
147
151
|
this.onResponse.set(`sink-push-chunk:${requestId}`, (chunkParsed, chunkTopicName) => {
|
|
148
|
-
if (chunkTopicName !== chunkParsed.name)
|
|
149
|
-
|
|
152
|
+
if (chunkTopicName !== chunkParsed.name) {
|
|
153
|
+
const error = new Error(`sink name mismatch (topic: "${chunkTopicName}", payload: "${chunkParsed.name}")`);
|
|
154
|
+
readable.destroy(error);
|
|
155
|
+
reqSpool.unroll();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
150
158
|
if (chunkParsed.error !== undefined) {
|
|
151
159
|
readable.destroy(new Error(chunkParsed.error));
|
|
152
160
|
reqSpool.unroll();
|
|
@@ -184,27 +192,34 @@ export class SinkTrait extends SourceTrait {
|
|
|
184
192
|
makeMutuallyExclusiveFields(info, "stream", "buffer");
|
|
185
193
|
/* send ack response */
|
|
186
194
|
await sendResponse();
|
|
195
|
+
ackSent = true;
|
|
187
196
|
/* call handler */
|
|
188
|
-
return callback(...params, info);
|
|
189
|
-
}
|
|
197
|
+
return await callback(...params, info);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
const error = ensureError(err);
|
|
190
201
|
/* cleanup resources */
|
|
191
202
|
const stream = this.pushStreams.get(requestId);
|
|
192
203
|
if (stream !== undefined)
|
|
193
|
-
stream.destroy(
|
|
204
|
+
stream.destroy(error);
|
|
194
205
|
reqSpool.unroll();
|
|
195
|
-
/* send error
|
|
196
|
-
this.error(
|
|
197
|
-
|
|
198
|
-
|
|
206
|
+
/* send error as nak response or as error chunk */
|
|
207
|
+
this.error(error);
|
|
208
|
+
if (ackSent) {
|
|
209
|
+
const chunkTopic = this.options.topicMake(name, "sink-push-chunk", sender);
|
|
210
|
+
const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error.message, true, this.options.id, sender);
|
|
211
|
+
const message = this.codec.encode(chunkMsg);
|
|
212
|
+
await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
await sendResponse(error.message).catch(() => { });
|
|
216
|
+
}
|
|
199
217
|
});
|
|
200
218
|
spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
|
|
201
219
|
/* subscribe to MQTT topics */
|
|
202
|
-
await
|
|
203
|
-
|
|
204
|
-
await
|
|
205
|
-
spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
|
|
206
|
-
await run(`subscribe to MQTT topic "${topicChunkD}"`, spool, () => this.subscribeTopic(topicChunkD, { qos: 2, ...options }));
|
|
207
|
-
spool.roll(() => this.unsubscribeTopic(topicChunkD).catch(() => { }));
|
|
220
|
+
await this.subscribeTopicAndSpool(spool, topicReqB, options);
|
|
221
|
+
await this.subscribeTopicAndSpool(spool, topicReqD, options);
|
|
222
|
+
await this.subscribeTopicAndSpool(spool, topicChunkD, options);
|
|
208
223
|
/* provide a registration for subsequent destruction */
|
|
209
224
|
return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
|
|
210
225
|
}
|
|
@@ -237,7 +252,10 @@ export class SinkTrait extends SourceTrait {
|
|
|
237
252
|
/* create a resource spool */
|
|
238
253
|
const spool = new Spool();
|
|
239
254
|
/* generate unique request id */
|
|
240
|
-
|
|
255
|
+
let requestId = nanoid();
|
|
256
|
+
while (this.onResponse.has(`sink-push-response:${requestId}`)
|
|
257
|
+
|| this.onResponse.has(`sink-push-credit:${requestId}`))
|
|
258
|
+
requestId = nanoid();
|
|
241
259
|
/* subscribe to response topic (for ack/nak) */
|
|
242
260
|
const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
|
|
243
261
|
await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
|
|
@@ -246,11 +264,12 @@ export class SinkTrait extends SourceTrait {
|
|
|
246
264
|
const abortController = new AbortController();
|
|
247
265
|
const abortSignal = abortController.signal;
|
|
248
266
|
/* utility function for timeout refresh */
|
|
249
|
-
const
|
|
267
|
+
const pushTimerId = `sink-push-send:${requestId}`;
|
|
268
|
+
const refreshTimeout = () => this.timerRefresh(pushTimerId, () => {
|
|
250
269
|
abortController.abort(new Error(`push to sink "${name}" timed out`));
|
|
251
270
|
spool.unroll();
|
|
252
271
|
});
|
|
253
|
-
spool.roll(() => { this.timerClear(
|
|
272
|
+
spool.roll(() => { this.timerClear(pushTimerId); });
|
|
254
273
|
/* start timeout handler */
|
|
255
274
|
refreshTimeout();
|
|
256
275
|
/* send request and wait for response before sending chunks */
|
|
@@ -327,11 +346,15 @@ export class SinkTrait extends SourceTrait {
|
|
|
327
346
|
await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
328
347
|
}
|
|
329
348
|
catch (err) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
349
|
+
/* send error chunk only if receiver is known
|
|
350
|
+
(otherwise the sink already received the error via the nak response) */
|
|
351
|
+
if (receiver !== undefined) {
|
|
352
|
+
const error = ensureError(err).message;
|
|
353
|
+
const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
|
|
354
|
+
const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
|
|
355
|
+
const message = this.codec.encode(chunkMsg);
|
|
356
|
+
await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
|
|
357
|
+
}
|
|
335
358
|
throw err;
|
|
336
359
|
}
|
|
337
360
|
finally {
|