mqtt-plus 1.4.23 → 1.4.25
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 +10386 -0
- package/.ase/service.yaml +1 -1
- 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 +23 -0
- package/README.md +4 -4
- package/dst-stage1/mqtt-plus-api.d.ts +6 -6
- package/dst-stage1/mqtt-plus-base.js +21 -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-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 +90 -36
- package/dst-stage2/mqtt-plus.esm.js +90 -36
- package/dst-stage2/mqtt-plus.umd.js +11 -11
- package/package.d/{vite+8.0.13.patch → vite+8.1.4.patch} +2 -2
- package/package.json +22 -24
- package/src/mqtt-plus-api.ts +5 -5
- package/src/mqtt-plus-base.ts +21 -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-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/package.d/{@typescript-eslint+typescript-estree+8.59.3.patch → @typescript-eslint+typescript-estree+8.63.0.patch} +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
diff --git a/node_modules/vite/dist/node/chunks/node.js b/node_modules/vite/dist/node/chunks/node.js
|
|
2
|
-
index
|
|
2
|
+
index 69b9f8c..f661ab9 100644
|
|
3
3
|
--- a/node_modules/vite/dist/node/chunks/node.js
|
|
4
4
|
+++ b/node_modules/vite/dist/node/chunks/node.js
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -36150,7 +36150,6 @@ async function runConfigHook(config, plugins, configEnv) {
|
|
6
6
|
const res = await getHookHandler(hook).call(context, conf, configEnv);
|
|
7
7
|
if (res && res !== conf) {
|
|
8
8
|
if (hasBothRollupOptionsAndRolldownOptions(res)) context.warn(`Both \`rollupOptions\` and \`rolldownOptions\` were specified by ${JSON.stringify(p.name)} plugin. \`rollupOptions\` specified by that plugin will be ignored.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mqtt-plus",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.25",
|
|
4
4
|
"description": "MQTT Communication Patterns",
|
|
5
5
|
"keywords": [ "mqtt",
|
|
6
6
|
"event", "emit",
|
|
@@ -31,30 +31,30 @@
|
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"patch-package": "8.0.1",
|
|
34
|
-
"@rse/stx": "1.1.
|
|
34
|
+
"@rse/stx": "1.1.6",
|
|
35
35
|
"shx": "0.4.0",
|
|
36
|
-
"eslint": "10.
|
|
37
|
-
"globals": "17.
|
|
36
|
+
"eslint": "10.7.0",
|
|
37
|
+
"globals": "17.7.0",
|
|
38
38
|
"@eslint/js": "10.0.1",
|
|
39
|
-
"@eslint/eslintrc": "3.3.
|
|
40
|
-
"knip": "6.
|
|
39
|
+
"@eslint/eslintrc": "3.3.6",
|
|
40
|
+
"knip": "6.26.0",
|
|
41
41
|
"jiti": "2.7.0",
|
|
42
|
-
"tsx": "4.
|
|
43
|
-
"mocha": "11.7.
|
|
42
|
+
"tsx": "4.23.0",
|
|
43
|
+
"mocha": "11.7.6",
|
|
44
44
|
"chai": "6.2.2",
|
|
45
45
|
"sinon-chai": "4.0.1",
|
|
46
46
|
"sinon": "22.0.0",
|
|
47
47
|
"c8": "11.0.0",
|
|
48
48
|
"source-map-support": "0.5.21",
|
|
49
49
|
"typescript": "6.0.3",
|
|
50
|
-
"typescript-eslint": "8.
|
|
51
|
-
"@typescript-eslint/parser": "8.
|
|
52
|
-
"mqtt": "5.15.
|
|
53
|
-
"aedes": "1.
|
|
50
|
+
"typescript-eslint": "8.63.0",
|
|
51
|
+
"@typescript-eslint/parser": "8.63.0",
|
|
52
|
+
"mqtt": "5.15.2",
|
|
53
|
+
"aedes": "1.1.1",
|
|
54
54
|
"mosquitto": "1.1.0",
|
|
55
55
|
"textframe": "1.2.1",
|
|
56
|
-
"vite": "8.
|
|
57
|
-
"vite-plugin-node-polyfills": "0.
|
|
56
|
+
"vite": "8.1.4",
|
|
57
|
+
"vite-plugin-node-polyfills": "0.28.0",
|
|
58
58
|
"@rollup/plugin-replace": "6.0.3",
|
|
59
59
|
"@wroud/vite-plugin-tsc": "0.12.3",
|
|
60
60
|
"@terrastruct/d2": "0.1.33",
|
|
@@ -63,28 +63,26 @@
|
|
|
63
63
|
"open-cli": "9.0.0",
|
|
64
64
|
"cross-env": "10.1.0",
|
|
65
65
|
|
|
66
|
-
"@types/node": "
|
|
66
|
+
"@types/node": "26.1.1",
|
|
67
67
|
"@types/mocha": "10.0.10",
|
|
68
68
|
"@types/chai": "5.2.3",
|
|
69
69
|
"@types/sinon-chai": "4.0.0",
|
|
70
|
-
"@types/sinon": "
|
|
70
|
+
"@types/sinon": "22.0.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"mqtt": ">=4.0.0"
|
|
74
74
|
},
|
|
75
75
|
"overrides": {
|
|
76
|
-
"vite-plugin-node-polyfills": { "vite": ">=7.0.0" }
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
"@typescript-eslint/typescript-estree": { "typescript": ">=5.9.0" },
|
|
81
|
-
"@typescript-eslint/tsconfig-utils": { "typescript": ">=5.9.0" }
|
|
76
|
+
"vite-plugin-node-polyfills": { "vite": ">=7.0.0" }
|
|
77
|
+
},
|
|
78
|
+
"allowScripts": {
|
|
79
|
+
"esbuild": true
|
|
82
80
|
},
|
|
83
81
|
"dependencies": {
|
|
84
|
-
"nanoid": "5.1.
|
|
82
|
+
"nanoid": "5.1.16",
|
|
85
83
|
"cbor2": "2.3.0",
|
|
86
84
|
"p-lazy": "5.0.0",
|
|
87
|
-
"valibot": "1.4.
|
|
85
|
+
"valibot": "1.4.2",
|
|
88
86
|
"jose": "6.2.3",
|
|
89
87
|
"@stablelib/pbkdf2": "2.0.1",
|
|
90
88
|
"@stablelib/sha256": "2.0.1"
|
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
|
@@ -110,8 +110,10 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
110
110
|
input = Buffer.isBuffer(message)
|
|
111
111
|
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
|
|
112
112
|
: message
|
|
113
|
-
else
|
|
114
|
-
|
|
113
|
+
else {
|
|
114
|
+
this.error(new Error("invalid codec configured"))
|
|
115
|
+
return
|
|
116
|
+
}
|
|
115
117
|
this._onMessage(topic, input, packet)
|
|
116
118
|
}
|
|
117
119
|
this.mqtt.on("message", this.messageHandler)
|
|
@@ -147,11 +149,19 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
147
149
|
protected async subscribeTopic (topic: string, options: Partial<IClientSubscribeOptions> = {}): Promise<void> {
|
|
148
150
|
this.log("info", `subscribing to MQTT topic "${topic}"`)
|
|
149
151
|
return new Promise<void>((resolve, reject) => {
|
|
150
|
-
this.mqtt.subscribe(topic, { qos: 2, ...options }, (err: Error | null,
|
|
152
|
+
this.mqtt.subscribe(topic, { qos: 2, ...options }, (err: Error | null, granted: any) => {
|
|
151
153
|
if (err) {
|
|
152
154
|
this.error(err, `subscribing to MQTT topic "${topic}" failed`)
|
|
153
155
|
reject(err)
|
|
154
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
|
+
}
|
|
155
165
|
else
|
|
156
166
|
resolve()
|
|
157
167
|
})
|
|
@@ -222,7 +232,14 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
|
|
|
222
232
|
return
|
|
223
233
|
|
|
224
234
|
/* parse MQTT topic */
|
|
225
|
-
|
|
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
|
+
}
|
|
226
243
|
if (topicMatch === null)
|
|
227
244
|
return
|
|
228
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-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
|
|