mqtt-plus 1.4.8 → 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 +15 -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 +42 -23
- package/dst-stage1/mqtt-plus-source.js +29 -18
- package/dst-stage1/mqtt-plus-subscription.js +12 -2
- package/dst-stage1/mqtt-plus-util.js +4 -2
- package/dst-stage1/mqtt-plus-version.js +1 -1
- package/dst-stage2/mqtt-plus.cjs.js +140 -98
- package/dst-stage2/mqtt-plus.esm.js +140 -98
- package/dst-stage2/mqtt-plus.umd.js +12 -12
- package/package.json +1 -1
- 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 +47 -27
- package/src/mqtt-plus-source.ts +33 -22
- package/src/mqtt-plus-subscription.ts +14 -2
- package/src/mqtt-plus-util.ts +5 -2
- package/src/mqtt-plus-version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
ChangeLog
|
|
3
3
|
=========
|
|
4
4
|
|
|
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
|
+
|
|
5
20
|
1.4.8 (2026-02-22)
|
|
6
21
|
------------------
|
|
7
22
|
|
|
@@ -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), `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 }));
|
|
@@ -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,34 @@ 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
|
-
return callback(...params, info);
|
|
192
|
-
}
|
|
197
|
+
return await callback(...params, info);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
const error = ensureError(err);
|
|
193
201
|
/* cleanup resources */
|
|
194
202
|
const stream = this.pushStreams.get(requestId);
|
|
195
203
|
if (stream !== undefined)
|
|
196
|
-
stream.destroy(
|
|
204
|
+
stream.destroy(error);
|
|
197
205
|
reqSpool.unroll();
|
|
198
|
-
/* send error
|
|
199
|
-
this.error(
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
}
|
|
202
217
|
});
|
|
203
218
|
spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
|
|
204
219
|
/* 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(() => { }));
|
|
220
|
+
await this.subscribeTopicAndSpool(spool, topicReqB, options);
|
|
221
|
+
await this.subscribeTopicAndSpool(spool, topicReqD, options);
|
|
222
|
+
await this.subscribeTopicAndSpool(spool, topicChunkD, options);
|
|
211
223
|
/* provide a registration for subsequent destruction */
|
|
212
224
|
return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
|
|
213
225
|
}
|
|
@@ -240,7 +252,10 @@ export class SinkTrait extends SourceTrait {
|
|
|
240
252
|
/* create a resource spool */
|
|
241
253
|
const spool = new Spool();
|
|
242
254
|
/* generate unique request id */
|
|
243
|
-
|
|
255
|
+
let requestId = nanoid();
|
|
256
|
+
while (this.onResponse.has(`sink-push-response:${requestId}`)
|
|
257
|
+
|| this.onResponse.has(`sink-push-credit:${requestId}`))
|
|
258
|
+
requestId = nanoid();
|
|
244
259
|
/* subscribe to response topic (for ack/nak) */
|
|
245
260
|
const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
|
|
246
261
|
await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
|
|
@@ -331,11 +346,15 @@ export class SinkTrait extends SourceTrait {
|
|
|
331
346
|
await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
332
347
|
}
|
|
333
348
|
catch (err) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
}
|
|
339
358
|
throw err;
|
|
340
359
|
}
|
|
341
360
|
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 ?? [];
|
|
@@ -125,15 +125,14 @@ export class SourceTrait extends ServiceTrait {
|
|
|
125
125
|
}
|
|
126
126
|
/* call the handler callback */
|
|
127
127
|
let ackSent = false;
|
|
128
|
-
|
|
128
|
+
try {
|
|
129
129
|
if (topicName !== request.name)
|
|
130
130
|
throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
|
|
131
131
|
if (auth)
|
|
132
132
|
info.authenticated = await this.authenticated(request.sender, request.auth, auth);
|
|
133
133
|
if (info.authenticated !== undefined && !info.authenticated)
|
|
134
134
|
throw new Error(`source "${name}" failed authentication`);
|
|
135
|
-
|
|
136
|
-
}).then(async () => {
|
|
135
|
+
await callback(...params, info);
|
|
137
136
|
/* check for valid data source */
|
|
138
137
|
if (!(info.stream instanceof Readable) && !(info.buffer instanceof Promise))
|
|
139
138
|
throw new Error("handler did not provide data via info.stream or info.buffer fields");
|
|
@@ -149,15 +148,17 @@ export class SourceTrait extends ServiceTrait {
|
|
|
149
148
|
else if (info.buffer instanceof Promise)
|
|
150
149
|
/* handle Buffer result */
|
|
151
150
|
await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate);
|
|
152
|
-
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
153
|
/* send error as nak response or as error chunk */
|
|
154
154
|
const error = ensureError(err);
|
|
155
155
|
this.error(error, `handler for source "${name}" failed`);
|
|
156
156
|
if (ackSent)
|
|
157
|
-
|
|
157
|
+
await sendChunk(undefined, error.message, true).catch(() => { });
|
|
158
158
|
else
|
|
159
|
-
|
|
160
|
-
}
|
|
159
|
+
await sendResponse(error.message).catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
161
162
|
/* cleanup resources */
|
|
162
163
|
clearSourceTimeout();
|
|
163
164
|
if (creditGate) {
|
|
@@ -165,16 +166,13 @@ export class SourceTrait extends ServiceTrait {
|
|
|
165
166
|
this.sourceCreditGates.delete(requestId);
|
|
166
167
|
}
|
|
167
168
|
this.onResponse.delete(`source-fetch-credit:${requestId}`);
|
|
168
|
-
}
|
|
169
|
+
}
|
|
169
170
|
});
|
|
170
171
|
spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
|
|
171
172
|
/* 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(() => { }));
|
|
173
|
+
await this.subscribeTopicAndSpool(spool, topicReqB, options);
|
|
174
|
+
await this.subscribeTopicAndSpool(spool, topicReqD, options);
|
|
175
|
+
await this.subscribeTopicAndSpool(spool, topicCreditD, options);
|
|
178
176
|
/* provide a registration for subsequent destruction */
|
|
179
177
|
return this.makeRegistration(spool, "source", name, `source-fetch-request:${name}`);
|
|
180
178
|
}
|
|
@@ -201,7 +199,10 @@ export class SourceTrait extends ServiceTrait {
|
|
|
201
199
|
/* create a resource spool */
|
|
202
200
|
const spool = new Spool();
|
|
203
201
|
/* generate unique request id */
|
|
204
|
-
|
|
202
|
+
let requestId = nanoid();
|
|
203
|
+
while (this.onResponse.has(`source-fetch-response:${requestId}`)
|
|
204
|
+
|| this.onResponse.has(`source-fetch-chunk:${requestId}`))
|
|
205
|
+
requestId = nanoid();
|
|
205
206
|
/* subscribe to response topic (for ack/nak) and chunk topic (for data) */
|
|
206
207
|
const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id);
|
|
207
208
|
const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id);
|
|
@@ -242,7 +243,7 @@ export class SourceTrait extends ServiceTrait {
|
|
|
242
243
|
const metaP = new Promise((resolve) => {
|
|
243
244
|
metaResolve = resolve;
|
|
244
245
|
});
|
|
245
|
-
spool.roll(() => { metaResolve
|
|
246
|
+
spool.roll(() => { metaResolve(undefined); });
|
|
246
247
|
/* define timer */
|
|
247
248
|
const timerId = `source-fetch:${requestId}`;
|
|
248
249
|
const refreshTimeout = () => {
|
|
@@ -259,9 +260,14 @@ export class SourceTrait extends ServiceTrait {
|
|
|
259
260
|
stream.once("error", () => spool.unroll());
|
|
260
261
|
/* register response dispatch callback */
|
|
261
262
|
this.onResponse.set(`source-fetch-response:${requestId}`, (response) => {
|
|
263
|
+
if (response.name !== name) {
|
|
264
|
+
stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
|
|
265
|
+
spool.unroll();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
262
268
|
if (response.sender)
|
|
263
269
|
serverId = response.sender;
|
|
264
|
-
metaResolve
|
|
270
|
+
metaResolve(response.meta);
|
|
265
271
|
if (response.error) {
|
|
266
272
|
stream.destroy(new Error(response.error));
|
|
267
273
|
spool.unroll();
|
|
@@ -271,6 +277,11 @@ export class SourceTrait extends ServiceTrait {
|
|
|
271
277
|
});
|
|
272
278
|
/* register chunk dispatch callback */
|
|
273
279
|
this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
|
|
280
|
+
if (response.name !== name) {
|
|
281
|
+
stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
|
|
282
|
+
spool.unroll();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
274
285
|
if (response.sender)
|
|
275
286
|
serverId = response.sender;
|
|
276
287
|
if (response.error) {
|
|
@@ -44,7 +44,7 @@ class RefCountedSubscription {
|
|
|
44
44
|
/* decrement reference count for a topic */
|
|
45
45
|
decrementCount(topic) {
|
|
46
46
|
const count = this.counts.get(topic);
|
|
47
|
-
if (count) {
|
|
47
|
+
if (count !== undefined) {
|
|
48
48
|
if (count <= 1) {
|
|
49
49
|
this.counts.delete(topic);
|
|
50
50
|
return 0;
|
|
@@ -136,15 +136,25 @@ class RefCountedSubscription {
|
|
|
136
136
|
/* flush all pending linger timers and unsubscribe */
|
|
137
137
|
async flush() {
|
|
138
138
|
/* determine all topics with potentially active subscriptions */
|
|
139
|
-
const topics = new Set([
|
|
139
|
+
const topics = new Set([
|
|
140
|
+
...this.counts.keys(),
|
|
141
|
+
...this.lingers.keys(),
|
|
142
|
+
...this.pending.keys(),
|
|
143
|
+
...this.unsubbing.keys()
|
|
144
|
+
]);
|
|
140
145
|
/* cancel all pending linger timers first (synchronously) */
|
|
141
146
|
for (const topic of this.lingers.keys())
|
|
142
147
|
clearTimeout(this.lingers.get(topic));
|
|
143
148
|
this.lingers.clear();
|
|
144
149
|
this.counts.clear();
|
|
150
|
+
/* wait for any in-flight subscribe/unsubscribe operations to settle first */
|
|
151
|
+
await Promise.allSettled([...this.pending.values(), ...this.unsubbing.values()]);
|
|
145
152
|
/* then unsubscribe from all potentially active topics */
|
|
146
153
|
for (const topic of topics)
|
|
147
154
|
await this.unsubscribeFn(topic).catch(() => { });
|
|
155
|
+
/* clear remaining internal state */
|
|
156
|
+
this.pending.clear();
|
|
157
|
+
this.unsubbing.clear();
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
/* Subscription trait with shared MQTT subscription management */
|
|
@@ -80,6 +80,8 @@ export class CreditGate {
|
|
|
80
80
|
this.waiters.shift()(true);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
/* reusable encoder instance */
|
|
84
|
+
const textEncoder = new TextEncoder();
|
|
83
85
|
/* concatenate elements of a Uint8Array array */
|
|
84
86
|
function uint8ArrayConcat(arrays) {
|
|
85
87
|
const totalLength = arrays.reduce((acc, value) => acc + value.byteLength, 0);
|
|
@@ -95,11 +97,11 @@ function uint8ArrayConcat(arrays) {
|
|
|
95
97
|
function chunkToBuffer(chunk) {
|
|
96
98
|
let buffer;
|
|
97
99
|
if (chunk instanceof Buffer)
|
|
98
|
-
buffer = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.
|
|
100
|
+
buffer = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
99
101
|
else if (chunk instanceof Uint8Array)
|
|
100
102
|
buffer = chunk;
|
|
101
103
|
else if (typeof chunk === "string")
|
|
102
|
-
buffer =
|
|
104
|
+
buffer = textEncoder.encode(chunk);
|
|
103
105
|
else
|
|
104
106
|
throw new Error("invalid chunk type: expected Buffer, Uint8Array, or string");
|
|
105
107
|
return buffer;
|
|
@@ -29,7 +29,7 @@ export const versionToNum = (str) => {
|
|
|
29
29
|
const minor = parseInt(m[2], 10);
|
|
30
30
|
if (minor > 99)
|
|
31
31
|
throw new Error("invalid version string: minor version exceeds 99");
|
|
32
|
-
return parseInt(m[1], 10) + minor
|
|
32
|
+
return parseInt(m[1], 10) * 100 + minor;
|
|
33
33
|
};
|
|
34
34
|
export const VERSION = __VERSION__;
|
|
35
35
|
/* package version (numeric format) */
|