mqtt-plus 1.4.22 → 1.4.24
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/.ase/service.log +10402 -0
- package/.ase/task/TASK-P18.md +47 -0
- package/.ase/task/TASK-P2.md +76 -0
- package/.ase/task/TASK-P21.md +75 -0
- package/.claude/settings.local.json +7 -0
- package/CHANGELOG.md +21 -1
- package/README.md +4 -4
- package/dst-stage1/mqtt-plus-api.d.ts +6 -6
- package/dst-stage1/mqtt-plus-base.js +34 -4
- package/dst-stage1/mqtt-plus-codec.js +15 -6
- package/dst-stage1/mqtt-plus-error.js +47 -14
- package/dst-stage1/mqtt-plus-event.js +1 -1
- package/dst-stage1/mqtt-plus-options.js +1 -3
- package/dst-stage1/mqtt-plus-service.js +1 -1
- package/dst-stage1/mqtt-plus-sink.js +10 -2
- package/dst-stage1/mqtt-plus-source.d.ts +1 -0
- package/dst-stage1/mqtt-plus-source.js +11 -3
- package/dst-stage1/mqtt-plus-subscription.js +7 -2
- package/dst-stage1/mqtt-plus-timer.js +4 -2
- package/dst-stage2/mqtt-plus.cjs.cjs +94 -36
- package/dst-stage2/mqtt-plus.esm.js +94 -36
- package/dst-stage2/mqtt-plus.umd.js +11 -11
- package/package.json +1 -1
- package/src/mqtt-plus-api.ts +5 -5
- package/src/mqtt-plus-base.ts +35 -4
- package/src/mqtt-plus-codec.ts +15 -8
- package/src/mqtt-plus-error.ts +52 -14
- package/src/mqtt-plus-event.ts +1 -1
- package/src/mqtt-plus-options.ts +1 -4
- package/src/mqtt-plus-service.ts +1 -1
- package/src/mqtt-plus-sink.ts +12 -2
- package/src/mqtt-plus-source.ts +13 -3
- package/src/mqtt-plus-subscription.ts +7 -2
- package/src/mqtt-plus-timer.ts +4 -2
- package/tst/mqtt-plus-4-sink.spec.ts +43 -0
- package/tst/mqtt-plus-5-source.spec.ts +35 -0
- package/tst/mqtt-plus-6-misc.spec.ts +82 -5
- package/tst/mqtt-plus-8-run.spec.ts +107 -0
- package/.ase/service.yaml +0 -1
package/package.json
CHANGED
package/src/mqtt-plus-api.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
/* utility type for branding */
|
|
26
|
-
type Brand<T> = T & { readonly __brand:
|
|
26
|
+
type Brand<T, K extends string> = T & { readonly __brand: K }
|
|
27
27
|
|
|
28
28
|
/* endpoint types */
|
|
29
29
|
export type APIEndpoint = APIEndpointEvent | APIEndpointService | APIEndpointSource | APIEndpointSink
|
|
@@ -33,10 +33,10 @@ export type APIEndpointSource = (...args: any[]) => void | Promise<void>
|
|
|
33
33
|
export type APIEndpointSink = (...args: any[]) => void | Promise<void>
|
|
34
34
|
|
|
35
35
|
/* API marker types */
|
|
36
|
-
export type Event<T extends APIEndpointEvent> = Brand<T>
|
|
37
|
-
export type Service<T extends APIEndpointService> = Brand<T>
|
|
38
|
-
export type Source<T extends APIEndpointSource> = Brand<T>
|
|
39
|
-
export type Sink<T extends APIEndpointSink> = Brand<T>
|
|
36
|
+
export type Event<T extends APIEndpointEvent> = Brand<T, "event">
|
|
37
|
+
export type Service<T extends APIEndpointService> = Brand<T, "service">
|
|
38
|
+
export type Source<T extends APIEndpointSource> = Brand<T, "source">
|
|
39
|
+
export type Sink<T extends APIEndpointSink> = Brand<T, "sink">
|
|
40
40
|
|
|
41
41
|
/* type utilities for generic API */
|
|
42
42
|
export type APISchema = Record<string, APIEndpoint>
|
package/src/mqtt-plus-base.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { type MqttClient,
|
|
|
28
28
|
type IClientSubscribeOptions,
|
|
29
29
|
type IClientPublishOptions,
|
|
30
30
|
type IPublishPacket } from "mqtt"
|
|
31
|
+
import { nanoid } from "nanoid"
|
|
31
32
|
|
|
32
33
|
/* internal requirements */
|
|
33
34
|
import type { APISchema, Registration } from "./mqtt-plus-api"
|
|
@@ -79,6 +80,19 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
79
80
|
/* store MQTT client */
|
|
80
81
|
this.mqtt = mqtt
|
|
81
82
|
|
|
83
|
+
/* resolve the instance "id": if the user did not provide an
|
|
84
|
+
explicit one, fetch the "clientId" from the underlying MQTT
|
|
85
|
+
client, and only as a last resort fall back to a generated id
|
|
86
|
+
(e.g. for the fake proxy MQTT client without a real clientId) */
|
|
87
|
+
if (this.options.id === "") {
|
|
88
|
+
const clientId = (mqtt as any).isFakeProxy
|
|
89
|
+
? undefined
|
|
90
|
+
: mqtt.options?.clientId
|
|
91
|
+
this.options.id = (typeof clientId === "string" && clientId !== "")
|
|
92
|
+
? clientId
|
|
93
|
+
: nanoid()
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/* hook into the MQTT message processing */
|
|
83
97
|
this.log("info", "hooking into MQTT client")
|
|
84
98
|
this.messageHandler = (topic, message, packet) => {
|
|
@@ -96,8 +110,10 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
96
110
|
input = Buffer.isBuffer(message)
|
|
97
111
|
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
|
|
98
112
|
: message
|
|
99
|
-
else
|
|
100
|
-
|
|
113
|
+
else {
|
|
114
|
+
this.error(new Error("invalid codec configured"))
|
|
115
|
+
return
|
|
116
|
+
}
|
|
101
117
|
this._onMessage(topic, input, packet)
|
|
102
118
|
}
|
|
103
119
|
this.mqtt.on("message", this.messageHandler)
|
|
@@ -133,11 +149,19 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
133
149
|
protected async subscribeTopic (topic: string, options: Partial<IClientSubscribeOptions> = {}): Promise<void> {
|
|
134
150
|
this.log("info", `subscribing to MQTT topic "${topic}"`)
|
|
135
151
|
return new Promise<void>((resolve, reject) => {
|
|
136
|
-
this.mqtt.subscribe(topic, { qos: 2, ...options }, (err: Error | null,
|
|
152
|
+
this.mqtt.subscribe(topic, { qos: 2, ...options }, (err: Error | null, granted: any) => {
|
|
137
153
|
if (err) {
|
|
138
154
|
this.error(err, `subscribing to MQTT topic "${topic}" failed`)
|
|
139
155
|
reject(err)
|
|
140
156
|
}
|
|
157
|
+
else if (Array.isArray(granted)
|
|
158
|
+
&& granted.some((grant: any) => typeof grant?.qos === "number" && grant.qos >= 0x80)) {
|
|
159
|
+
/* the broker rejected the subscription via SUBACK grant/reason code */
|
|
160
|
+
const error = new Error(`broker rejected subscription to MQTT topic "${topic}" ` +
|
|
161
|
+
`(grant codes: ${granted.map((grant: any) => grant?.qos).join(", ")})`)
|
|
162
|
+
this.error(error, `subscribing to MQTT topic "${topic}" failed`)
|
|
163
|
+
reject(error)
|
|
164
|
+
}
|
|
141
165
|
else
|
|
142
166
|
resolve()
|
|
143
167
|
})
|
|
@@ -208,7 +232,14 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
208
232
|
return
|
|
209
233
|
|
|
210
234
|
/* parse MQTT topic */
|
|
211
|
-
|
|
235
|
+
let topicMatch
|
|
236
|
+
try {
|
|
237
|
+
topicMatch = this.options.topicMatch(topic)
|
|
238
|
+
}
|
|
239
|
+
catch (err: unknown) {
|
|
240
|
+
this.error(ensureError(err, "failed to match MQTT topic"))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
212
243
|
if (topicMatch === null)
|
|
213
244
|
return
|
|
214
245
|
|
package/src/mqtt-plus-codec.ts
CHANGED
|
@@ -39,17 +39,24 @@ export class JSONX {
|
|
|
39
39
|
return new Uint8Array(Buffer.from(base64, "base64"))
|
|
40
40
|
}
|
|
41
41
|
static stringify (obj: unknown): string {
|
|
42
|
-
return JSON.stringify(obj, (
|
|
43
|
-
value
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
return JSON.stringify(obj, function (key, value) {
|
|
43
|
+
/* re-read the original value from the holder, as JSON.stringify
|
|
44
|
+
applies Buffer.prototype.toJSON before calling the replacer */
|
|
45
|
+
const orig: unknown = this[key]
|
|
46
|
+
return orig instanceof Buffer
|
|
47
|
+
? { __Buffer: JSONX.uint8ArrayToBase64(orig) }
|
|
48
|
+
: orig instanceof Uint8Array
|
|
49
|
+
? { __Uint8Array: JSONX.uint8ArrayToBase64(orig) }
|
|
50
|
+
: value
|
|
51
|
+
})
|
|
47
52
|
}
|
|
48
53
|
static parse (json: string): unknown {
|
|
49
54
|
return JSON.parse(json, (_, value) =>
|
|
50
|
-
typeof value?.
|
|
51
|
-
?
|
|
52
|
-
: value
|
|
55
|
+
typeof value?.__Buffer === "string"
|
|
56
|
+
? Buffer.from(value.__Buffer, "base64")
|
|
57
|
+
: typeof value?.__Uint8Array === "string"
|
|
58
|
+
? this.base64ToUint8Array(value.__Uint8Array)
|
|
59
|
+
: value
|
|
53
60
|
)
|
|
54
61
|
}
|
|
55
62
|
}
|
package/src/mqtt-plus-error.ts
CHANGED
|
@@ -201,6 +201,43 @@ function runUnroll (isAsync: boolean, spool?: Spool): Promise<void> | void {
|
|
|
201
201
|
return result
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/* helper function for settling the epilog of "run": run the finally
|
|
205
|
+
code isolated, unroll the spool, and preserve a pending error */
|
|
206
|
+
function runSettle (isAsync: false, pending: boolean, spool?: Spool, onfinally?: () => void, description?: string): void
|
|
207
|
+
function runSettle (isAsync: true, pending: boolean, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<void>
|
|
208
|
+
function runSettle (isAsync: boolean, pending: boolean, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<void> | void {
|
|
209
|
+
if (!isAsync) {
|
|
210
|
+
let failure: Error | undefined
|
|
211
|
+
try {
|
|
212
|
+
runFinally(false, onfinally as (() => void) | undefined, description)
|
|
213
|
+
}
|
|
214
|
+
catch (arg: unknown) {
|
|
215
|
+
failure = ensureError(arg, description)
|
|
216
|
+
}
|
|
217
|
+
if (pending || failure !== undefined) {
|
|
218
|
+
runUnroll(false, spool)
|
|
219
|
+
if (!pending && failure !== undefined)
|
|
220
|
+
throw failure
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
return (async () => {
|
|
225
|
+
let failure: Error | undefined
|
|
226
|
+
try {
|
|
227
|
+
await runFinally(true, onfinally, description)
|
|
228
|
+
}
|
|
229
|
+
catch (arg: unknown) {
|
|
230
|
+
failure = ensureError(arg, description)
|
|
231
|
+
}
|
|
232
|
+
if (pending || failure !== undefined) {
|
|
233
|
+
await runUnroll(true, spool)
|
|
234
|
+
if (!pending && failure !== undefined)
|
|
235
|
+
throw failure
|
|
236
|
+
}
|
|
237
|
+
})()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
204
241
|
/* helper type for ensuring T contains no Promise */
|
|
205
242
|
type RunNoPromise<T> =
|
|
206
243
|
[ T ] extends [ Promise<any> ] ? never : T
|
|
@@ -350,50 +387,51 @@ export function run<T> (
|
|
|
350
387
|
}
|
|
351
388
|
catch (arg: unknown) {
|
|
352
389
|
error = ensureError(arg, description)
|
|
353
|
-
|
|
354
|
-
runUnroll(false, spool)
|
|
390
|
+
runSettle(false, true, spool, onfinally, description)
|
|
355
391
|
throw error
|
|
356
392
|
}
|
|
357
|
-
|
|
393
|
+
if (spool && oncleanup)
|
|
394
|
+
spool.roll(result, oncleanup as SpoolCleanup<unknown>)
|
|
395
|
+
runSettle(false, false, spool, onfinally, description)
|
|
358
396
|
return result
|
|
359
397
|
}
|
|
360
|
-
|
|
361
|
-
runUnroll(false, spool)
|
|
398
|
+
runSettle(false, true, spool, onfinally, description)
|
|
362
399
|
throw error
|
|
363
400
|
}
|
|
364
401
|
if (result instanceof Promise) {
|
|
365
402
|
/* asynchronous case (result or error branch) */
|
|
366
403
|
return result.then(async (result) => {
|
|
367
|
-
await runFinally(true, onfinally, description)
|
|
368
404
|
if (spool && oncleanup)
|
|
369
405
|
spool.roll(result, oncleanup as SpoolCleanup<unknown>)
|
|
406
|
+
await runSettle(true, false, spool, onfinally, description)
|
|
370
407
|
return result
|
|
371
408
|
}, async (arg: unknown) => {
|
|
372
409
|
/* asynchronous case (error branch) */
|
|
373
410
|
let error = ensureError(arg, description)
|
|
374
411
|
if (oncatch) {
|
|
412
|
+
let result: T
|
|
375
413
|
try {
|
|
376
|
-
|
|
377
|
-
await runFinally(true, onfinally, description)
|
|
378
|
-
return result
|
|
414
|
+
result = await oncatch(error)
|
|
379
415
|
}
|
|
380
416
|
catch (arg: unknown) {
|
|
381
417
|
error = ensureError(arg, description)
|
|
382
|
-
await
|
|
383
|
-
await runUnroll(true, spool)
|
|
418
|
+
await runSettle(true, true, spool, onfinally, description)
|
|
384
419
|
throw error
|
|
385
420
|
}
|
|
421
|
+
if (spool && oncleanup)
|
|
422
|
+
spool.roll(result, oncleanup as SpoolCleanup<unknown>)
|
|
423
|
+
await runSettle(true, false, spool, onfinally, description)
|
|
424
|
+
return result
|
|
386
425
|
}
|
|
387
|
-
await
|
|
388
|
-
await runUnroll(true, spool)
|
|
426
|
+
await runSettle(true, true, spool, onfinally, description)
|
|
389
427
|
throw error
|
|
390
428
|
})
|
|
391
429
|
}
|
|
392
430
|
else {
|
|
393
431
|
/* synchronous case (result branch) */
|
|
394
|
-
runFinally(false, onfinally, description)
|
|
395
432
|
if (spool && oncleanup)
|
|
396
433
|
spool.roll(result, oncleanup as SpoolCleanup<unknown>)
|
|
434
|
+
runSettle(false, false, spool, onfinally, description)
|
|
397
435
|
return result
|
|
398
436
|
}
|
|
399
437
|
}
|
package/src/mqtt-plus-event.ts
CHANGED
|
@@ -168,7 +168,7 @@ export class EventTrait<T extends APISchema = APISchema> extends AuthTrait<T> {
|
|
|
168
168
|
|
|
169
169
|
/* asynchronously execute handler */
|
|
170
170
|
try {
|
|
171
|
-
if (auth)
|
|
171
|
+
if (auth !== undefined)
|
|
172
172
|
info.authenticated = await this.authenticated(senderId, request.auth, auth, `event "${name}"`)
|
|
173
173
|
await callback(...params, info)
|
|
174
174
|
}
|
package/src/mqtt-plus-options.ts
CHANGED
|
@@ -22,9 +22,6 @@
|
|
|
22
22
|
** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
/* external requirements */
|
|
26
|
-
import { nanoid } from "nanoid"
|
|
27
|
-
|
|
28
25
|
/* internal requirements */
|
|
29
26
|
import type { APISchema } from "./mqtt-plus-api"
|
|
30
27
|
|
|
@@ -55,7 +52,7 @@ export class OptionsTrait<_T extends APISchema = APISchema> {
|
|
|
55
52
|
) {
|
|
56
53
|
/* determine options and provide defaults */
|
|
57
54
|
this.options = {
|
|
58
|
-
id:
|
|
55
|
+
id: "", /* intentionally resolved later in BaseTrait */
|
|
59
56
|
codec: "cbor",
|
|
60
57
|
timeout: 10 * 1000,
|
|
61
58
|
share: "",
|
package/src/mqtt-plus-service.ts
CHANGED
|
@@ -186,7 +186,7 @@ export class ServiceTrait<T extends APISchema = APISchema> extends EventTrait<T>
|
|
|
186
186
|
/* execute handler and send response */
|
|
187
187
|
let removeAbortListener: (() => void) | undefined
|
|
188
188
|
try {
|
|
189
|
-
if (auth)
|
|
189
|
+
if (auth !== undefined)
|
|
190
190
|
info.authenticated = await this.authenticated(senderId, request.auth, auth, `service "${name}"`)
|
|
191
191
|
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
192
192
|
const onAbort = () => {
|
package/src/mqtt-plus-sink.ts
CHANGED
|
@@ -229,7 +229,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
229
229
|
if (topicName !== request.name)
|
|
230
230
|
throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`)
|
|
231
231
|
let authenticated: boolean | undefined = undefined
|
|
232
|
-
if (auth)
|
|
232
|
+
if (auth !== undefined)
|
|
233
233
|
authenticated = await this.authenticated(sender, request.auth, auth, `sink "${name}"`)
|
|
234
234
|
|
|
235
235
|
/* initialize credit-based flow control state */
|
|
@@ -247,7 +247,16 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
247
247
|
const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
|
|
248
248
|
if (streamEnded || this.destroying)
|
|
249
249
|
return
|
|
250
|
-
|
|
250
|
+
const error = new Error("push stream timeout")
|
|
251
|
+
abortController.abort(error)
|
|
252
|
+
|
|
253
|
+
/* destroy the push stream so that streamDone settles even when
|
|
254
|
+
the callback already completed before the timeout fired */
|
|
255
|
+
streamEnded = true
|
|
256
|
+
const stream = this.pushStreams.get(requestId)
|
|
257
|
+
if (stream !== undefined && !stream.destroyed)
|
|
258
|
+
stream.destroy(error)
|
|
259
|
+
|
|
251
260
|
/* eagerly notify sender so it stops publishing chunks
|
|
252
261
|
(suppress the spool-rollback cancel via errorResponseSent) */
|
|
253
262
|
if (sender && !errorResponseSent) {
|
|
@@ -666,6 +675,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
666
675
|
pushInitialResolve = resolve
|
|
667
676
|
pushInitialReject = reject
|
|
668
677
|
})
|
|
678
|
+
pushInitial.catch(() => {}) /* avoid unhandled promise rejection */
|
|
669
679
|
this.onResponse.set(`sink-push-response:${requestId}`, (response: SinkPushResponse) => {
|
|
670
680
|
if (response.name !== name) {
|
|
671
681
|
const error = new Error(`sink response name mismatch (expected "${name}", got "${response.name}")`)
|
package/src/mqtt-plus-source.ts
CHANGED
|
@@ -50,8 +50,14 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
50
50
|
private sourceSpools = new Map<string, Spool>()
|
|
51
51
|
private sourceRequests = new Map<string, Set<string>>()
|
|
52
52
|
|
|
53
|
+
/* fetch state */
|
|
54
|
+
private fetchAborts = new Map<string, (error: Error) => void>()
|
|
55
|
+
|
|
53
56
|
/* destroy source trait */
|
|
54
57
|
override async destroy () {
|
|
58
|
+
for (const abort of this.fetchAborts.values())
|
|
59
|
+
abort(new Error("instance destroyed"))
|
|
60
|
+
this.fetchAborts.clear()
|
|
55
61
|
for (const controller of this.sourceControllers.values())
|
|
56
62
|
controller.abort(new Error("source destroyed"))
|
|
57
63
|
for (const gate of this.sourceCreditGates.values())
|
|
@@ -143,8 +149,6 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
143
149
|
|
|
144
150
|
/* create a resource spool for request cleanup */
|
|
145
151
|
const reqSpool = new Spool()
|
|
146
|
-
this.sourceSpools.set(requestId, reqSpool)
|
|
147
|
-
reqSpool.roll(() => { this.sourceSpools.delete(requestId) })
|
|
148
152
|
|
|
149
153
|
/* sanity check sender */
|
|
150
154
|
if (sender === undefined || sender === "") {
|
|
@@ -180,6 +184,8 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
180
184
|
}
|
|
181
185
|
this.sourceControllers.set(requestId, abortController)
|
|
182
186
|
reqSpool.roll(() => { this.sourceControllers.delete(requestId) })
|
|
187
|
+
this.sourceSpools.set(requestId, reqSpool)
|
|
188
|
+
reqSpool.roll(() => { this.sourceSpools.delete(requestId) })
|
|
183
189
|
|
|
184
190
|
/* provide info object */
|
|
185
191
|
const info: InfoSource = { sender, signal: abortSignal }
|
|
@@ -295,7 +301,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
295
301
|
})
|
|
296
302
|
|
|
297
303
|
/* check for authentication */
|
|
298
|
-
if (auth)
|
|
304
|
+
if (auth !== undefined)
|
|
299
305
|
info.authenticated = await this.authenticated(sender, request.auth, auth, `source "${name}"`)
|
|
300
306
|
|
|
301
307
|
/* finally call the handler callback */
|
|
@@ -566,6 +572,10 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
566
572
|
stream.destroy(error)
|
|
567
573
|
}
|
|
568
574
|
|
|
575
|
+
/* register abort handler at instance level (for destroy) */
|
|
576
|
+
this.fetchAborts.set(requestId, endWithError)
|
|
577
|
+
spool.roll(() => { this.fetchAborts.delete(requestId) })
|
|
578
|
+
|
|
569
579
|
/* ensure resources are released if consumer aborts stream early */
|
|
570
580
|
let cancelled = false
|
|
571
581
|
const cancelAndUnroll = (reason?: unknown) => {
|
|
@@ -127,11 +127,16 @@ class RefCountedSubscription {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
/* unsubscribe from a topic (reference-counted)
|
|
130
|
+
/* unsubscribe from a topic (reference-counted)
|
|
131
|
+
NOTICE: shared subscriptions ("$share/...") are never lingered, as a
|
|
132
|
+
lingering group member would still receive its round-robin share of
|
|
133
|
+
the group's messages and silently drop them, and would additionally
|
|
134
|
+
cause duplicate deliveries once an overlapping non-shared
|
|
135
|
+
subscription is established on the same connection */
|
|
131
136
|
async unsubscribe (topic: string): Promise<void> {
|
|
132
137
|
const count = this.decrementCount(topic)
|
|
133
138
|
if (count === 0) {
|
|
134
|
-
if (this.lingerMs > 0) {
|
|
139
|
+
if (this.lingerMs > 0 && !topic.startsWith("$share/")) {
|
|
135
140
|
/* defer the actual broker unsubscription */
|
|
136
141
|
const timer = setTimeout(() => {
|
|
137
142
|
this.lingers.delete(topic)
|
package/src/mqtt-plus-timer.ts
CHANGED
|
@@ -82,7 +82,8 @@ export class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTra
|
|
|
82
82
|
ac.abort()
|
|
83
83
|
resolve()
|
|
84
84
|
}, durationMs)
|
|
85
|
-
timer.unref
|
|
85
|
+
if (typeof timer.unref === "function")
|
|
86
|
+
timer.unref()
|
|
86
87
|
if (signal !== undefined) {
|
|
87
88
|
if (ac !== undefined)
|
|
88
89
|
signal.addEventListener("abort", () => {
|
|
@@ -109,7 +110,8 @@ export class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTra
|
|
|
109
110
|
ac.abort()
|
|
110
111
|
reject(new Error(info))
|
|
111
112
|
}, durationMs)
|
|
112
|
-
timer.unref
|
|
113
|
+
if (typeof timer.unref === "function")
|
|
114
|
+
timer.unref()
|
|
113
115
|
if (signal !== undefined) {
|
|
114
116
|
if (ac !== undefined)
|
|
115
117
|
signal.addEventListener("abort", () => {
|
|
@@ -319,5 +319,48 @@ describe("MQTT+ Sink Push", function () {
|
|
|
319
319
|
/* cleanup */
|
|
320
320
|
await sinking.destroy()
|
|
321
321
|
})
|
|
322
|
+
|
|
323
|
+
/* test case: Sink Push (Stalled After Early Callback) */
|
|
324
|
+
it("MQTT+ Sink Push (Stalled After Early Callback)", async function () {
|
|
325
|
+
/* setup */
|
|
326
|
+
this.slow(4000)
|
|
327
|
+
this.timeout(4000)
|
|
328
|
+
const spy = sinon.spy()
|
|
329
|
+
|
|
330
|
+
/* establish sink whose callback resolves immediately, ignoring stream/buffer */
|
|
331
|
+
const sinking = await ctx.apiS.sink("example/server/upload", (name: string, _info) => {
|
|
332
|
+
spy("sink")
|
|
333
|
+
expect(name).to.be.equal("foo")
|
|
334
|
+
/* intentionally do not consume info.stream or info.buffer */
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
/* create a readable that emits one chunk and then stalls forever */
|
|
338
|
+
const chunkSize = 16 * 1024
|
|
339
|
+
let emitted = false
|
|
340
|
+
const readable = new stream.Readable({
|
|
341
|
+
read () {
|
|
342
|
+
if (!emitted) {
|
|
343
|
+
emitted = true
|
|
344
|
+
this.push(Buffer.from(crypto.randomBytes(chunkSize)))
|
|
345
|
+
}
|
|
346
|
+
/* never push more and never end -- simulate a stalled sender */
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
/* the push must fail (via receiver push timeout) instead of hanging */
|
|
351
|
+
await ctx.apiC.push("example/server/upload", readable, "foo").then(() => {
|
|
352
|
+
spy("push-success")
|
|
353
|
+
}).catch(() => {
|
|
354
|
+
spy("push-error")
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
/* wait for receiver-side cleanup to settle */
|
|
358
|
+
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
359
|
+
expect(spy.getCalls().map((call) => call.firstArg))
|
|
360
|
+
.to.be.same.deep.members([ "sink", "push-error" ])
|
|
361
|
+
|
|
362
|
+
/* cleanup */
|
|
363
|
+
await sinking.destroy()
|
|
364
|
+
})
|
|
322
365
|
})
|
|
323
366
|
|
|
@@ -31,8 +31,13 @@ import { Buffer } from "node:buffer"
|
|
|
31
31
|
import { describe, it } from "mocha"
|
|
32
32
|
import * as chai from "chai"
|
|
33
33
|
|
|
34
|
+
/* external dependencies (application) */
|
|
35
|
+
import MQTT from "mqtt"
|
|
36
|
+
|
|
34
37
|
/* internal dependencies */
|
|
38
|
+
import MQTTp from "mqtt-plus"
|
|
35
39
|
import { ctx } from "./mqtt-plus-0-fixture"
|
|
40
|
+
import type { API } from "./mqtt-plus-0-fixture"
|
|
36
41
|
|
|
37
42
|
/* setup test suite infrastructure */
|
|
38
43
|
chai.config.includeStack = true
|
|
@@ -263,5 +268,35 @@ describe("MQTT+ Source Fetch", function () {
|
|
|
263
268
|
/* cleanup */
|
|
264
269
|
await sourcing.destroy()
|
|
265
270
|
})
|
|
271
|
+
|
|
272
|
+
/* test case: Source Fetch (Destroy In-Flight) */
|
|
273
|
+
it("MQTT+ Source Fetch (Destroy In-Flight)", async function () {
|
|
274
|
+
/* setup */
|
|
275
|
+
this.slow(2000)
|
|
276
|
+
this.timeout(2000)
|
|
277
|
+
|
|
278
|
+
/* connect to broker as a dedicated client with a long communication timeout */
|
|
279
|
+
const mqttClient = MQTT.connect("mqtt://127.0.0.1:1883")
|
|
280
|
+
await new Promise<void>((resolve, reject) => {
|
|
281
|
+
mqttClient.once("connect", () => { resolve() })
|
|
282
|
+
mqttClient.once("error", (err: Error) => { reject(err) })
|
|
283
|
+
})
|
|
284
|
+
const apiClient = new MQTTp<API>(mqttClient, { timeout: 60 * 1000 })
|
|
285
|
+
|
|
286
|
+
/* start fetching a source nobody responds to */
|
|
287
|
+
const result = await apiClient.fetch("example/server/download", "foo")
|
|
288
|
+
|
|
289
|
+
/* destroy the instance while the fetch is still in-flight */
|
|
290
|
+
await apiClient.destroy()
|
|
291
|
+
|
|
292
|
+
/* expect meta and buffer to settle well below the communication timeout */
|
|
293
|
+
const errorMeta = await result.meta.catch((err: Error) => err.message)
|
|
294
|
+
expect(errorMeta).to.be.equal("instance destroyed")
|
|
295
|
+
const errorBuffer = await result.buffer.catch((err: Error) => err.message)
|
|
296
|
+
expect(errorBuffer).to.be.equal("instance destroyed")
|
|
297
|
+
|
|
298
|
+
/* cleanup */
|
|
299
|
+
await mqttClient.endAsync()
|
|
300
|
+
})
|
|
266
301
|
})
|
|
267
302
|
|
|
@@ -36,6 +36,7 @@ import MQTT from "mqtt"
|
|
|
36
36
|
|
|
37
37
|
/* internal dependencies */
|
|
38
38
|
import MQTTp from "mqtt-plus"
|
|
39
|
+
import type { Event } from "mqtt-plus"
|
|
39
40
|
import { ctx } from "./mqtt-plus-0-fixture"
|
|
40
41
|
import type { API } from "./mqtt-plus-0-fixture"
|
|
41
42
|
import { makeMutuallyExclusiveFields } from "../src/mqtt-plus-util"
|
|
@@ -113,31 +114,76 @@ describe("MQTT+ Miscellaneous", function () {
|
|
|
113
114
|
/* setup */
|
|
114
115
|
this.slow(2000)
|
|
115
116
|
this.timeout(2000)
|
|
116
|
-
const spy
|
|
117
|
+
const spy = sinon.spy()
|
|
118
|
+
const spyB = sinon.spy()
|
|
117
119
|
|
|
118
120
|
/* create JSON codec API instances on existing MQTT connections */
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
+
type APIX = API & { "example/server/binary": Event<(data: Buffer) => void> }
|
|
122
|
+
const apiJsonS = new MQTTp<APIX>(ctx.mqttS, { id: "json-server", codec: "json", timeout: 500 })
|
|
123
|
+
const apiJsonC = new MQTTp<APIX>(ctx.mqttC, { id: "json-client", codec: "json", timeout: 500 })
|
|
121
124
|
|
|
122
|
-
/* register event
|
|
125
|
+
/* register event handlers */
|
|
123
126
|
const registration = await apiJsonS.event("example/server/sample", (str: string, num: number) => {
|
|
124
127
|
spy("event", str, num)
|
|
125
128
|
})
|
|
129
|
+
const registrationB = await apiJsonS.event("example/server/binary", (data: Buffer) => {
|
|
130
|
+
spyB(data)
|
|
131
|
+
})
|
|
126
132
|
|
|
127
|
-
/* emit
|
|
133
|
+
/* emit events via JSON codec */
|
|
128
134
|
apiJsonC.emit("example/server/sample", "hello", 99)
|
|
135
|
+
apiJsonC.emit("example/server/binary", Buffer.from([ 0x01, 0x02, 0x03, 0xff ]))
|
|
129
136
|
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
|
130
137
|
|
|
131
138
|
/* verify round-trip */
|
|
132
139
|
expect(spy.getCalls().length).to.equal(1)
|
|
133
140
|
expect(spy.getCalls()[0].args).to.deep.equal([ "event", "hello", 99 ])
|
|
134
141
|
|
|
142
|
+
/* verify Buffer round-trip */
|
|
143
|
+
expect(spyB.getCalls().length).to.equal(1)
|
|
144
|
+
const data: Buffer = spyB.getCalls()[0].args[0]
|
|
145
|
+
expect(data).to.be.instanceOf(Buffer)
|
|
146
|
+
expect(data.equals(Buffer.from([ 0x01, 0x02, 0x03, 0xff ]))).to.equal(true)
|
|
147
|
+
|
|
135
148
|
/* cleanup */
|
|
136
149
|
await registration.destroy()
|
|
150
|
+
await registrationB.destroy()
|
|
137
151
|
apiJsonS.destroy()
|
|
138
152
|
apiJsonC.destroy()
|
|
139
153
|
})
|
|
140
154
|
|
|
155
|
+
/* test case: Error Event on throwing topicMatch */
|
|
156
|
+
it("MQTT+ Error Event on Throwing topicMatch", async function () {
|
|
157
|
+
/* setup */
|
|
158
|
+
this.slow(2000)
|
|
159
|
+
this.timeout(2000)
|
|
160
|
+
const spy = sinon.spy()
|
|
161
|
+
|
|
162
|
+
/* create API instances with a throwing user-supplied topicMatch on the receiver side */
|
|
163
|
+
const apiThrowS = new MQTTp<API>(ctx.mqttS, { id: "throw-server", timeout: 500,
|
|
164
|
+
topicMatch: () => { throw new Error("intentionally failing topicMatch") } })
|
|
165
|
+
const apiThrowC = new MQTTp<API>(ctx.mqttC, { id: "throw-client", timeout: 500 })
|
|
166
|
+
|
|
167
|
+
/* observe MQTT+ error events */
|
|
168
|
+
apiThrowS.on("error", (err: Error) => { spy(err.message) })
|
|
169
|
+
|
|
170
|
+
/* register event handler to subscribe the topic */
|
|
171
|
+
const registration = await apiThrowS.event("example/server/sample", () => {})
|
|
172
|
+
|
|
173
|
+
/* emit event to trigger the inbound message processing */
|
|
174
|
+
apiThrowC.emit("example/server/sample", "hello", 42)
|
|
175
|
+
await new Promise((resolve) => { setTimeout(resolve, 100) })
|
|
176
|
+
|
|
177
|
+
/* ensure the exception surfaced as MQTT+ "error" event */
|
|
178
|
+
expect(spy.getCalls().length).to.be.at.least(1)
|
|
179
|
+
expect(spy.getCalls()[0].args[0]).to.match(/failed to match MQTT topic/)
|
|
180
|
+
|
|
181
|
+
/* cleanup */
|
|
182
|
+
await registration.destroy()
|
|
183
|
+
apiThrowS.destroy()
|
|
184
|
+
apiThrowC.destroy()
|
|
185
|
+
})
|
|
186
|
+
|
|
141
187
|
/* test case: Authentication */
|
|
142
188
|
it("MQTT+ Authentication", async function () {
|
|
143
189
|
/* setup */
|
|
@@ -222,6 +268,37 @@ describe("MQTT+ Miscellaneous", function () {
|
|
|
222
268
|
await registration2.destroy()
|
|
223
269
|
})
|
|
224
270
|
|
|
271
|
+
/* test case: Authentication with Empty Role */
|
|
272
|
+
it("MQTT+ Authentication with Empty Role", async function () {
|
|
273
|
+
/* setup */
|
|
274
|
+
this.slow(2000)
|
|
275
|
+
this.timeout(2000)
|
|
276
|
+
const spy = sinon.spy()
|
|
277
|
+
|
|
278
|
+
/* server-side: provide hello service with falsy-but-valid empty role */
|
|
279
|
+
const registration = await ctx.apiS.service({
|
|
280
|
+
name: "example/server/hello",
|
|
281
|
+
auth: "",
|
|
282
|
+
callback: (str: string, num: number) => {
|
|
283
|
+
spy("hello")
|
|
284
|
+
return `${str}:${num}`
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
/* call service and expect fail-closed rejection */
|
|
289
|
+
await ctx.apiC.call("example/server/hello", "world", 42).then(async (_result) => {
|
|
290
|
+
spy("call-success")
|
|
291
|
+
}).catch((err: Error) => {
|
|
292
|
+
spy("call-error")
|
|
293
|
+
expect(err.message).to.match(/failed authentication/)
|
|
294
|
+
})
|
|
295
|
+
expect(spy.getCalls().map((call) => call.firstArg))
|
|
296
|
+
.to.be.deep.equal([ "call-error" ])
|
|
297
|
+
|
|
298
|
+
/* cleanup */
|
|
299
|
+
await registration.destroy()
|
|
300
|
+
})
|
|
301
|
+
|
|
225
302
|
/* test case: Unit: arr2buf/buf2arr */
|
|
226
303
|
it("MQTT+ Unit: arr2buf/buf2arr", function () {
|
|
227
304
|
/* create a dry-run MQTTp instance for accessing encode methods */
|