mqtt-plus 1.4.23 → 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 +10381 -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 +18 -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 +89 -35
- package/dst-stage2/mqtt-plus.esm.js +89 -35
- 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 +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/.ase/service.yaml +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
|
|
2
|
+
# TASK P18: Guard pushInitial against unhandled rejection
|
|
3
|
+
|
|
4
|
+
⎈ Created: 2026-07-07 00:01
|
|
5
|
+
⚙ Modified: 2026-07-07 00:01
|
|
6
|
+
|
|
7
|
+
## CONTEXT
|
|
8
|
+
|
|
9
|
+
- **WHAT**: Add the missing rejection guard `pushInitial.catch(() => {})` in `push()` of `SinkTrait`
|
|
10
|
+
(`src/mqtt-plus-sink.ts:665-668`), mirroring the existing `pushFinalize` guard
|
|
11
|
+
(`src/mqtt-plus-sink.ts:637`).
|
|
12
|
+
|
|
13
|
+
- **WHY**: When publishing the push request fails (`src/mqtt-plus-sink.ts:795-796`), the `catch`
|
|
14
|
+
block aborts (`src/mqtt-plus-sink.ts:866`) and `onAbort` (`src/mqtt-plus-sink.ts:773-783`) calls
|
|
15
|
+
`pushInitialReject(error)` before `await pushInitial` (`src/mqtt-plus-sink.ts:797`) ever attached
|
|
16
|
+
a handler — an *unhandled promise rejection*, fatal by default in Node.js ≥ 15. The same hazard
|
|
17
|
+
exists for early rejections from the response/credit handlers while the publish is still pending.
|
|
18
|
+
|
|
19
|
+
## CHANGES
|
|
20
|
+
|
|
21
|
+
- **Guard**: Insert `pushInitial.catch(() => {}) /* avoid unhandled promise rejection */`
|
|
22
|
+
directly after the `pushInitial` promise creation, identical in style to the `pushFinalize`
|
|
23
|
+
guard and the three other existing guards in the code base. Unified diff preview:
|
|
24
|
+
|
|
25
|
+
```diff
|
|
26
|
+
--- a/src/mqtt-plus-sink.ts
|
|
27
|
+
+++ b/src/mqtt-plus-sink.ts
|
|
28
|
+
@@ -665,6 +665,7 @@
|
|
29
|
+
const pushInitial = new Promise<void>((resolve, reject) => {
|
|
30
|
+
pushInitialResolve = resolve
|
|
31
|
+
pushInitialReject = reject
|
|
32
|
+
})
|
|
33
|
+
+ pushInitial.catch(() => {}) /* avoid unhandled promise rejection */
|
|
34
|
+
this.onResponse.set(`sink-push-response:${requestId}`, (response: SinkPushResponse) => {
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **Changelog**: Add a new `BUGFIX` bullet to the current `1.4.24` section of `CHANGELOG.md`,
|
|
38
|
+
aligned with its existing style: "BUGFIX: guard the sink push ack promise against an unhandled
|
|
39
|
+
promise rejection when publishing the push request fails".
|
|
40
|
+
|
|
41
|
+
## VERIFICATION
|
|
42
|
+
|
|
43
|
+
- **Regression**: Run `npm start build test` and require the complete unit test suite to pass.
|
|
44
|
+
|
|
45
|
+
- **Behavior**: `await pushInitial` (`src/mqtt-plus-sink.ts:797`) still observes rejections, since
|
|
46
|
+
`.catch()` only derives a separate guard promise; a failed request publish now surfaces solely
|
|
47
|
+
as the thrown `run()` error, without any unhandled promise rejection.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
|
|
2
|
+
# TASK P2: Route inbound sync throws through error event
|
|
3
|
+
|
|
4
|
+
⎈ Created: 2026-07-06 13:40
|
|
5
|
+
⚙ Modified: 2026-07-06 13:40
|
|
6
|
+
|
|
7
|
+
## CONTEXT
|
|
8
|
+
|
|
9
|
+
- **WHAT**: Guard the two unprotected *synchronous throw sites* on the inbound MQTT `message` path
|
|
10
|
+
in `src/mqtt-plus-base.ts`: replace `throw new Error("invalid codec configured")` in the
|
|
11
|
+
`message` handler (line 114) with `this.error(...)` plus early `return`, and wrap the
|
|
12
|
+
*user-supplied* `this.options.topicMatch(topic)` call in `_onMessage` (line 225) in
|
|
13
|
+
`try`/`catch` routed to `this.error(ensureError(...))`.
|
|
14
|
+
|
|
15
|
+
- **WHY**: Both exceptions currently escape the `EventEmitter` `message` listener into MQTT.js
|
|
16
|
+
packet handling and surface as *uncaught exceptions*, while `codec.decode` and `msg.parse`
|
|
17
|
+
failures are already routed through the MQTT+ `error` event (`src/mqtt-plus-base.ts:235-251`)
|
|
18
|
+
-- this restores a *consistent error contract* on the hot inbound path.
|
|
19
|
+
|
|
20
|
+
## CHANGES
|
|
21
|
+
|
|
22
|
+
- **Codec guard**: in the `BaseTrait` message handler (`src/mqtt-plus-base.ts:113-114`), replace
|
|
23
|
+
the `else` branch `throw new Error("invalid codec configured")` with
|
|
24
|
+
`this.error(new Error("invalid codec configured"))` followed by an early `return`.
|
|
25
|
+
|
|
26
|
+
- **Topic match guard**: in `_onMessage` (`src/mqtt-plus-base.ts:225`), change
|
|
27
|
+
`const topicMatch = this.options.topicMatch(topic)` to a `let` declaration assigned inside
|
|
28
|
+
`try`/`catch`, with the `catch` calling `this.error(ensureError(err, "failed to match MQTT
|
|
29
|
+
topic"))` and returning -- mirroring the existing `codec.decode`/`msg.parse` guards.
|
|
30
|
+
|
|
31
|
+
- **Diff preview**:
|
|
32
|
+
```diff
|
|
33
|
+
--- src/mqtt-plus-base.ts
|
|
34
|
+
+++ src/mqtt-plus-base.ts
|
|
35
|
+
@@ constructor: messageHandler @@
|
|
36
|
+
else if (this.options.codec === "cbor")
|
|
37
|
+
input = Buffer.isBuffer(message)
|
|
38
|
+
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
|
|
39
|
+
: message
|
|
40
|
+
- else
|
|
41
|
+
- throw new Error("invalid codec configured")
|
|
42
|
+
+ else {
|
|
43
|
+
+ this.error(new Error("invalid codec configured"))
|
|
44
|
+
+ return
|
|
45
|
+
+ }
|
|
46
|
+
this._onMessage(topic, input, packet)
|
|
47
|
+
@@ _onMessage: topic parsing @@
|
|
48
|
+
/* parse MQTT topic */
|
|
49
|
+
- const topicMatch = this.options.topicMatch(topic)
|
|
50
|
+
+ let topicMatch
|
|
51
|
+
+ try {
|
|
52
|
+
+ topicMatch = this.options.topicMatch(topic)
|
|
53
|
+
+ }
|
|
54
|
+
+ catch (err: unknown) {
|
|
55
|
+
+ this.error(ensureError(err, "failed to match MQTT topic"))
|
|
56
|
+
+ return
|
|
57
|
+
+ }
|
|
58
|
+
if (topicMatch === null)
|
|
59
|
+
return
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **Test**: add a test to `tst/mqtt-plus-6-misc.spec.ts` asserting that a throwing user-supplied
|
|
63
|
+
`topicMatch` raises the MQTT+ `error` event on message reception instead of an uncaught
|
|
64
|
+
exception.
|
|
65
|
+
|
|
66
|
+
- **CHANGELOG**: add a `- BUGFIX: ...` entry to `CHANGELOG.md`, aligned with its existing style,
|
|
67
|
+
describing that synchronous errors of the inbound MQTT message handler are now routed through
|
|
68
|
+
the `error` event instead of escaping into MQTT.js.
|
|
69
|
+
|
|
70
|
+
## VERIFICATION
|
|
71
|
+
|
|
72
|
+
- **Regression suite**: run `npm start build test` and confirm the full unit test suite passes
|
|
73
|
+
against the automatically started MQTT broker.
|
|
74
|
+
|
|
75
|
+
- **Behavioral check**: confirm the new `topicMatch` test fails before the fix (uncaught
|
|
76
|
+
exception) and passes after it (error surfaces via the MQTT+ `error` event).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
|
|
2
|
+
# TASK P21: Abort in-flight fetches on destroy
|
|
3
|
+
|
|
4
|
+
⎈ Created: 2026-07-06 21:42
|
|
5
|
+
⚙ Modified: 2026-07-06 21:42
|
|
6
|
+
|
|
7
|
+
## CONTEXT
|
|
8
|
+
|
|
9
|
+
- **WHAT**: Track every in-flight `fetch()` in a new instance-level `SourceTrait` map `fetchAborts`
|
|
10
|
+
and abort all entries in `destroy()` through the existing per-fetch `endWithError` teardown path.
|
|
11
|
+
|
|
12
|
+
- **WHY**: `destroy()` cannot reach the fetcher-side closure state (`src/mqtt-plus-source.ts:457`),
|
|
13
|
+
so pending `fetch()` consumers hang forever on `stream`/`buffer`/`meta` (the timeout timer is
|
|
14
|
+
cleared and neutralized, the `onResponse` handlers are dropped); aborting settles all three with
|
|
15
|
+
*"instance destroyed"*, analogous to the service-caller `pendingCalls` pattern
|
|
16
|
+
(`src/mqtt-plus-service.ts:51-54`).
|
|
17
|
+
|
|
18
|
+
## CHANGES
|
|
19
|
+
|
|
20
|
+
- **State**: add `private fetchAborts = new Map<string, (error: Error) => void>()` as new
|
|
21
|
+
*fetch state* to `SourceTrait` (`src/mqtt-plus-source.ts`).
|
|
22
|
+
|
|
23
|
+
- **Destroy**: in `SourceTrait#destroy`, before the source-side cleanup, invoke every registered
|
|
24
|
+
abort handler with `new Error("instance destroyed")` and clear the map. `endWithError` sets
|
|
25
|
+
`streamEnded` (suppresses the cancel-credit publish), rejects `meta`, destroys the `ReadableTee`
|
|
26
|
+
(rejects `buffer`, errors `stream`) and triggers the spool unroll via the `close`/`error`
|
|
27
|
+
listeners.
|
|
28
|
+
|
|
29
|
+
- **Registration**: in `fetch()`, directly after the `endWithError` definition, register it via
|
|
30
|
+
`this.fetchAborts.set(requestId, endWithError)` and deregister via
|
|
31
|
+
`spool.roll(() => { this.fetchAborts.delete(requestId) })`, so every normal, error, or cancel
|
|
32
|
+
completion removes the entry.
|
|
33
|
+
|
|
34
|
+
- **CHANGELOG**: add entry `- BUGFIX: track in-flight fetch() operations and abort them on
|
|
35
|
+
destroy() so their stream/buffer/meta settle` to `CHANGELOG.md`, aligned with existing style.
|
|
36
|
+
|
|
37
|
+
- **Diff preview**:
|
|
38
|
+
|
|
39
|
+
```diff
|
|
40
|
+
--- a/src/mqtt-plus-source.ts
|
|
41
|
+
+++ b/src/mqtt-plus-source.ts
|
|
42
|
+
@@ -50,8 +50,14 @@
|
|
43
|
+
private sourceSpools = new Map<string, Spool>()
|
|
44
|
+
private sourceRequests = new Map<string, Set<string>>()
|
|
45
|
+
|
|
46
|
+
+ /* fetch state */
|
|
47
|
+
+ private fetchAborts = new Map<string, (error: Error) => void>()
|
|
48
|
+
+
|
|
49
|
+
/* destroy source trait */
|
|
50
|
+
override async destroy () {
|
|
51
|
+
+ for (const abort of this.fetchAborts.values())
|
|
52
|
+
+ abort(new Error("instance destroyed"))
|
|
53
|
+
+ this.fetchAborts.clear()
|
|
54
|
+
for (const controller of this.sourceControllers.values())
|
|
55
|
+
controller.abort(new Error("source destroyed"))
|
|
56
|
+
@@ -562,6 +568,10 @@
|
|
57
|
+
const endWithError = (error: Error) => {
|
|
58
|
+
streamEnded = true
|
|
59
|
+
metaReject(error)
|
|
60
|
+
stream.destroy(error)
|
|
61
|
+
}
|
|
62
|
+
+
|
|
63
|
+
+ /* register abort handler at instance level (for destroy) */
|
|
64
|
+
+ this.fetchAborts.set(requestId, endWithError)
|
|
65
|
+
+ spool.roll(() => { this.fetchAborts.delete(requestId) })
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## VERIFICATION
|
|
69
|
+
|
|
70
|
+
- **Unit test**: in `tst/mqtt-plus-5-source.spec.ts`, start a `fetch()` whose source never
|
|
71
|
+
responds, call `destroy()`, and expect `meta`/`buffer` to reject with *"instance destroyed"*
|
|
72
|
+
(and `stream` to emit the same error) well below the communication timeout.
|
|
73
|
+
|
|
74
|
+
- **Regression**: run `npm start build test` (lint-free build, full unit test suite against the
|
|
75
|
+
auto-started broker) and expect all existing source and fetch tests to still pass.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
ChangeLog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
+
1.4.24 (2026-07-06)
|
|
6
|
+
-------------------
|
|
7
|
+
|
|
8
|
+
- BUGFIX: isolate failing onfinally callbacks in run() so the spool is still unrolled and the original error is preserved
|
|
9
|
+
- BUGFIX: destroy the sink push receive stream on push timeout so a receiver whose callback completed early no longer hangs at the stream drain and leaks per-request state
|
|
10
|
+
- BUGFIX: register source request cleanup spool only after duplicate-request-id guard, so a duplicate request no longer corrupts the spool tracking of the in-flight original request
|
|
11
|
+
- BUGFIX: guard the sink push ack promise against an unhandled promise rejection when publishing the push request fails
|
|
12
|
+
- BUGFIX: reject requests instead of skipping authentication when the falsy-but-valid auth option value "" is used (event/service/source/sink traits)
|
|
13
|
+
- BUGFIX: never linger the unsubscription of shared subscriptions ("$share/..."), as a lingering group member still receives and silently drops its round-robin share of messages and causes duplicate deliveries alongside overlapping non-shared subscriptions
|
|
14
|
+
- BUGFIX: reject subscribeTopic() promise when the broker denies a subscription via SUBACK grant/reason codes >= 0x80 instead of silently resolving
|
|
15
|
+
- BUGFIX: guard timer.unref() calls in sleep()/timeout() against browser runtimes where setTimeout() returns a plain number
|
|
16
|
+
- BUGFIX: use distinct type brands per API marker type so endpoint kinds Event/Service/Source/Sink are discriminated nominally instead of structurally
|
|
17
|
+
- BUGFIX: route synchronous errors of the inbound MQTT message handler through the "error" event instead of letting them escape into MQTT.js
|
|
18
|
+
- BUGFIX: track in-flight fetch() operations and abort them on destroy() so their stream/buffer/meta settle
|
|
19
|
+
- BUGFIX: encode Buffer values correctly with the JSON codec, as Buffer.prototype.toJSON runs before the JSON.stringify replacer
|
|
20
|
+
- BUGFIX: register the oncatch recovery value with oncleanup on the spool in run() so fallback resources are disposed on unroll
|
|
21
|
+
- BUGFIX: invoke the onfinally callback of run() only once when oncatch recovers but onfinally fails
|
|
22
|
+
|
|
5
23
|
1.4.22 (2026-06-08)
|
|
6
24
|
-------------------
|
|
7
25
|
|
package/README.md
CHANGED
|
@@ -176,22 +176,22 @@ Notice
|
|
|
176
176
|
|
|
177
177
|
> [!Note]
|
|
178
178
|
> **MQTT+** and its peer dependency **MQTT.js** provide a powerful
|
|
179
|
-
> functionality, but are not small in size. **MQTT+** is 4
|
|
179
|
+
> functionality, but are not small in size. **MQTT+** is 4,000 LoC
|
|
180
180
|
> and 75 KB in size (ESM and CJS format). When bundled with all its
|
|
181
181
|
> dependencies, it is 220 KB in size (UMD format). Its peer dependency
|
|
182
182
|
> **MQTT.js** is 370 KB (ESM and CJS format) and 860 KB (UMD format) in
|
|
183
|
-
> size. For a Node.js application, this usually doesn't matter. For
|
|
183
|
+
> size. For a Node.js application, this usually doesn't matter. For an
|
|
184
184
|
> HTML5 SPA it matters more, but usually is still acceptable.
|
|
185
185
|
|
|
186
186
|
> [!Note]
|
|
187
187
|
> **MQTT+** is still somewhat similar to and originally derived from the weaker
|
|
188
188
|
> [MQTT-JSON-RPC](https://github.com/rse/mqtt-json-rpc) library of the same
|
|
189
189
|
> author. But instead of just JSON, MQTT+ encodes packets as JSON
|
|
190
|
-
> or CBOR (default), uses
|
|
190
|
+
> or CBOR (default), uses its own packet format (allowing sender and
|
|
191
191
|
> receiver information), uses shorter NanoIDs instead of longer UUIDs
|
|
192
192
|
> for identification of sender, receiver and requests, and additionally
|
|
193
193
|
> provides source/sink transfer support (with fetch and push capabilities),
|
|
194
|
-
> has an authentication mechanism, supports meta-data passing, and
|
|
194
|
+
> has an authentication mechanism, supports meta-data passing, and much more.
|
|
195
195
|
|
|
196
196
|
License
|
|
197
197
|
-------
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
type Brand<T> = T & {
|
|
2
|
-
readonly __brand:
|
|
1
|
+
type Brand<T, K extends string> = T & {
|
|
2
|
+
readonly __brand: K;
|
|
3
3
|
};
|
|
4
4
|
export type APIEndpoint = APIEndpointEvent | APIEndpointService | APIEndpointSource | APIEndpointSink;
|
|
5
5
|
export type APIEndpointEvent = (...args: any[]) => void | Promise<void>;
|
|
6
6
|
export type APIEndpointService = (...args: any[]) => any | Promise<any>;
|
|
7
7
|
export type APIEndpointSource = (...args: any[]) => void | Promise<void>;
|
|
8
8
|
export type APIEndpointSink = (...args: any[]) => void | Promise<void>;
|
|
9
|
-
export type Event<T extends APIEndpointEvent> = Brand<T>;
|
|
10
|
-
export type Service<T extends APIEndpointService> = Brand<T>;
|
|
11
|
-
export type Source<T extends APIEndpointSource> = Brand<T>;
|
|
12
|
-
export type Sink<T extends APIEndpointSink> = Brand<T>;
|
|
9
|
+
export type Event<T extends APIEndpointEvent> = Brand<T, "event">;
|
|
10
|
+
export type Service<T extends APIEndpointService> = Brand<T, "service">;
|
|
11
|
+
export type Source<T extends APIEndpointSource> = Brand<T, "source">;
|
|
12
|
+
export type Sink<T extends APIEndpointSink> = Brand<T, "sink">;
|
|
13
13
|
export type APISchema = Record<string, APIEndpoint>;
|
|
14
14
|
export type EventKeys<T> = string extends keyof T ? string : {
|
|
15
15
|
[K in keyof T]: T[K] extends Event<infer _F> ? K : never;
|
|
@@ -84,8 +84,10 @@ export class BaseTrait extends TraceTrait {
|
|
|
84
84
|
input = Buffer.isBuffer(message)
|
|
85
85
|
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
|
|
86
86
|
: message;
|
|
87
|
-
else
|
|
88
|
-
|
|
87
|
+
else {
|
|
88
|
+
this.error(new Error("invalid codec configured"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
89
91
|
this._onMessage(topic, input, packet);
|
|
90
92
|
};
|
|
91
93
|
this.mqtt.on("message", this.messageHandler);
|
|
@@ -118,11 +120,19 @@ export class BaseTrait extends TraceTrait {
|
|
|
118
120
|
async subscribeTopic(topic, options = {}) {
|
|
119
121
|
this.log("info", `subscribing to MQTT topic "${topic}"`);
|
|
120
122
|
return new Promise((resolve, reject) => {
|
|
121
|
-
this.mqtt.subscribe(topic, { qos: 2, ...options }, (err,
|
|
123
|
+
this.mqtt.subscribe(topic, { qos: 2, ...options }, (err, granted) => {
|
|
122
124
|
if (err) {
|
|
123
125
|
this.error(err, `subscribing to MQTT topic "${topic}" failed`);
|
|
124
126
|
reject(err);
|
|
125
127
|
}
|
|
128
|
+
else if (Array.isArray(granted)
|
|
129
|
+
&& granted.some((grant) => typeof grant?.qos === "number" && grant.qos >= 0x80)) {
|
|
130
|
+
/* the broker rejected the subscription via SUBACK grant/reason code */
|
|
131
|
+
const error = new Error(`broker rejected subscription to MQTT topic "${topic}" ` +
|
|
132
|
+
`(grant codes: ${granted.map((grant) => grant?.qos).join(", ")})`);
|
|
133
|
+
this.error(error, `subscribing to MQTT topic "${topic}" failed`);
|
|
134
|
+
reject(error);
|
|
135
|
+
}
|
|
126
136
|
else
|
|
127
137
|
resolve();
|
|
128
138
|
});
|
|
@@ -183,7 +193,14 @@ export class BaseTrait extends TraceTrait {
|
|
|
183
193
|
if (this.destroyed)
|
|
184
194
|
return;
|
|
185
195
|
/* parse MQTT topic */
|
|
186
|
-
|
|
196
|
+
let topicMatch;
|
|
197
|
+
try {
|
|
198
|
+
topicMatch = this.options.topicMatch(topic);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
this.error(ensureError(err, "failed to match MQTT topic"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
187
204
|
if (topicMatch === null)
|
|
188
205
|
return;
|
|
189
206
|
/* parse MQTT data into payload object */
|
|
@@ -34,14 +34,23 @@ export class JSONX {
|
|
|
34
34
|
return new Uint8Array(Buffer.from(base64, "base64"));
|
|
35
35
|
}
|
|
36
36
|
static stringify(obj) {
|
|
37
|
-
return JSON.stringify(obj, (
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
return JSON.stringify(obj, function (key, value) {
|
|
38
|
+
/* re-read the original value from the holder, as JSON.stringify
|
|
39
|
+
applies Buffer.prototype.toJSON before calling the replacer */
|
|
40
|
+
const orig = this[key];
|
|
41
|
+
return orig instanceof Buffer
|
|
42
|
+
? { __Buffer: JSONX.uint8ArrayToBase64(orig) }
|
|
43
|
+
: orig instanceof Uint8Array
|
|
44
|
+
? { __Uint8Array: JSONX.uint8ArrayToBase64(orig) }
|
|
45
|
+
: value;
|
|
46
|
+
});
|
|
40
47
|
}
|
|
41
48
|
static parse(json) {
|
|
42
|
-
return JSON.parse(json, (_, value) => typeof value?.
|
|
43
|
-
?
|
|
44
|
-
: value
|
|
49
|
+
return JSON.parse(json, (_, value) => typeof value?.__Buffer === "string"
|
|
50
|
+
? Buffer.from(value.__Buffer, "base64")
|
|
51
|
+
: typeof value?.__Uint8Array === "string"
|
|
52
|
+
? this.base64ToUint8Array(value.__Uint8Array)
|
|
53
|
+
: value);
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
/* the encoder/decoder abstraction */
|
|
@@ -179,6 +179,38 @@ function runUnroll(isAsync, spool) {
|
|
|
179
179
|
result = Promise.resolve(result);
|
|
180
180
|
return result;
|
|
181
181
|
}
|
|
182
|
+
function runSettle(isAsync, pending, spool, onfinally, description) {
|
|
183
|
+
if (!isAsync) {
|
|
184
|
+
let failure;
|
|
185
|
+
try {
|
|
186
|
+
runFinally(false, onfinally, description);
|
|
187
|
+
}
|
|
188
|
+
catch (arg) {
|
|
189
|
+
failure = ensureError(arg, description);
|
|
190
|
+
}
|
|
191
|
+
if (pending || failure !== undefined) {
|
|
192
|
+
runUnroll(false, spool);
|
|
193
|
+
if (!pending && failure !== undefined)
|
|
194
|
+
throw failure;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
return (async () => {
|
|
199
|
+
let failure;
|
|
200
|
+
try {
|
|
201
|
+
await runFinally(true, onfinally, description);
|
|
202
|
+
}
|
|
203
|
+
catch (arg) {
|
|
204
|
+
failure = ensureError(arg, description);
|
|
205
|
+
}
|
|
206
|
+
if (pending || failure !== undefined) {
|
|
207
|
+
await runUnroll(true, spool);
|
|
208
|
+
if (!pending && failure !== undefined)
|
|
209
|
+
throw failure;
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
182
214
|
export function run(...args) {
|
|
183
215
|
/* support overloaded signatures */
|
|
184
216
|
let description;
|
|
@@ -243,50 +275,51 @@ export function run(...args) {
|
|
|
243
275
|
}
|
|
244
276
|
catch (arg) {
|
|
245
277
|
error = ensureError(arg, description);
|
|
246
|
-
|
|
247
|
-
runUnroll(false, spool);
|
|
278
|
+
runSettle(false, true, spool, onfinally, description);
|
|
248
279
|
throw error;
|
|
249
280
|
}
|
|
250
|
-
|
|
281
|
+
if (spool && oncleanup)
|
|
282
|
+
spool.roll(result, oncleanup);
|
|
283
|
+
runSettle(false, false, spool, onfinally, description);
|
|
251
284
|
return result;
|
|
252
285
|
}
|
|
253
|
-
|
|
254
|
-
runUnroll(false, spool);
|
|
286
|
+
runSettle(false, true, spool, onfinally, description);
|
|
255
287
|
throw error;
|
|
256
288
|
}
|
|
257
289
|
if (result instanceof Promise) {
|
|
258
290
|
/* asynchronous case (result or error branch) */
|
|
259
291
|
return result.then(async (result) => {
|
|
260
|
-
await runFinally(true, onfinally, description);
|
|
261
292
|
if (spool && oncleanup)
|
|
262
293
|
spool.roll(result, oncleanup);
|
|
294
|
+
await runSettle(true, false, spool, onfinally, description);
|
|
263
295
|
return result;
|
|
264
296
|
}, async (arg) => {
|
|
265
297
|
/* asynchronous case (error branch) */
|
|
266
298
|
let error = ensureError(arg, description);
|
|
267
299
|
if (oncatch) {
|
|
300
|
+
let result;
|
|
268
301
|
try {
|
|
269
|
-
|
|
270
|
-
await runFinally(true, onfinally, description);
|
|
271
|
-
return result;
|
|
302
|
+
result = await oncatch(error);
|
|
272
303
|
}
|
|
273
304
|
catch (arg) {
|
|
274
305
|
error = ensureError(arg, description);
|
|
275
|
-
await
|
|
276
|
-
await runUnroll(true, spool);
|
|
306
|
+
await runSettle(true, true, spool, onfinally, description);
|
|
277
307
|
throw error;
|
|
278
308
|
}
|
|
309
|
+
if (spool && oncleanup)
|
|
310
|
+
spool.roll(result, oncleanup);
|
|
311
|
+
await runSettle(true, false, spool, onfinally, description);
|
|
312
|
+
return result;
|
|
279
313
|
}
|
|
280
|
-
await
|
|
281
|
-
await runUnroll(true, spool);
|
|
314
|
+
await runSettle(true, true, spool, onfinally, description);
|
|
282
315
|
throw error;
|
|
283
316
|
});
|
|
284
317
|
}
|
|
285
318
|
else {
|
|
286
319
|
/* synchronous case (result branch) */
|
|
287
|
-
runFinally(false, onfinally, description);
|
|
288
320
|
if (spool && oncleanup)
|
|
289
321
|
spool.roll(result, oncleanup);
|
|
322
|
+
runSettle(false, false, spool, onfinally, description);
|
|
290
323
|
return result;
|
|
291
324
|
}
|
|
292
325
|
}
|
|
@@ -122,7 +122,7 @@ export class EventTrait extends AuthTrait {
|
|
|
122
122
|
info.meta = request.meta;
|
|
123
123
|
/* asynchronously execute handler */
|
|
124
124
|
try {
|
|
125
|
-
if (auth)
|
|
125
|
+
if (auth !== undefined)
|
|
126
126
|
info.authenticated = await this.authenticated(senderId, request.auth, auth, `event "${name}"`);
|
|
127
127
|
await callback(...params, info);
|
|
128
128
|
}
|
|
@@ -138,7 +138,7 @@ export class ServiceTrait extends EventTrait {
|
|
|
138
138
|
/* execute handler and send response */
|
|
139
139
|
let removeAbortListener;
|
|
140
140
|
try {
|
|
141
|
-
if (auth)
|
|
141
|
+
if (auth !== undefined)
|
|
142
142
|
info.authenticated = await this.authenticated(senderId, request.auth, auth, `service "${name}"`);
|
|
143
143
|
const abortPromise = new Promise((_resolve, reject) => {
|
|
144
144
|
const onAbort = () => {
|
|
@@ -172,7 +172,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
172
172
|
if (topicName !== request.name)
|
|
173
173
|
throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`);
|
|
174
174
|
let authenticated = undefined;
|
|
175
|
-
if (auth)
|
|
175
|
+
if (auth !== undefined)
|
|
176
176
|
authenticated = await this.authenticated(sender, request.auth, auth, `sink "${name}"`);
|
|
177
177
|
/* initialize credit-based flow control state */
|
|
178
178
|
const creditState = chunkCredit > 0 ? {
|
|
@@ -187,7 +187,14 @@ export class SinkTrait extends SourceTrait {
|
|
|
187
187
|
const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
|
|
188
188
|
if (streamEnded || this.destroying)
|
|
189
189
|
return;
|
|
190
|
-
|
|
190
|
+
const error = new Error("push stream timeout");
|
|
191
|
+
abortController.abort(error);
|
|
192
|
+
/* destroy the push stream so that streamDone settles even when
|
|
193
|
+
the callback already completed before the timeout fired */
|
|
194
|
+
streamEnded = true;
|
|
195
|
+
const stream = this.pushStreams.get(requestId);
|
|
196
|
+
if (stream !== undefined && !stream.destroyed)
|
|
197
|
+
stream.destroy(error);
|
|
191
198
|
/* eagerly notify sender so it stops publishing chunks
|
|
192
199
|
(suppress the spool-rollback cancel via errorResponseSent) */
|
|
193
200
|
if (sender && !errorResponseSent) {
|
|
@@ -543,6 +550,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
543
550
|
pushInitialResolve = resolve;
|
|
544
551
|
pushInitialReject = reject;
|
|
545
552
|
});
|
|
553
|
+
pushInitial.catch(() => { }); /* avoid unhandled promise rejection */
|
|
546
554
|
this.onResponse.set(`sink-push-response:${requestId}`, (response) => {
|
|
547
555
|
if (response.name !== name) {
|
|
548
556
|
const error = new Error(`sink response name mismatch (expected "${name}", got "${response.name}")`);
|
|
@@ -9,6 +9,7 @@ export declare class SourceTrait<T extends APISchema = APISchema> extends Servic
|
|
|
9
9
|
private sourceControllers;
|
|
10
10
|
private sourceSpools;
|
|
11
11
|
private sourceRequests;
|
|
12
|
+
private fetchAborts;
|
|
12
13
|
destroy(): Promise<void>;
|
|
13
14
|
source<K extends SourceKeys<T> & string>(name: K, callback: WithInfo<T[K], InfoSource>): Promise<Registration>;
|
|
14
15
|
source<K extends SourceKeys<T> & string>(config: {
|
|
@@ -37,9 +37,14 @@ export class SourceTrait extends ServiceTrait {
|
|
|
37
37
|
this.sourceControllers = new Map();
|
|
38
38
|
this.sourceSpools = new Map();
|
|
39
39
|
this.sourceRequests = new Map();
|
|
40
|
+
/* fetch state */
|
|
41
|
+
this.fetchAborts = new Map();
|
|
40
42
|
}
|
|
41
43
|
/* destroy source trait */
|
|
42
44
|
async destroy() {
|
|
45
|
+
for (const abort of this.fetchAborts.values())
|
|
46
|
+
abort(new Error("instance destroyed"));
|
|
47
|
+
this.fetchAborts.clear();
|
|
43
48
|
for (const controller of this.sourceControllers.values())
|
|
44
49
|
controller.abort(new Error("source destroyed"));
|
|
45
50
|
for (const gate of this.sourceCreditGates.values())
|
|
@@ -99,8 +104,6 @@ export class SourceTrait extends ServiceTrait {
|
|
|
99
104
|
const receiver = request.receiver;
|
|
100
105
|
/* create a resource spool for request cleanup */
|
|
101
106
|
const reqSpool = new Spool();
|
|
102
|
-
this.sourceSpools.set(requestId, reqSpool);
|
|
103
|
-
reqSpool.roll(() => { this.sourceSpools.delete(requestId); });
|
|
104
107
|
/* sanity check sender */
|
|
105
108
|
if (sender === undefined || sender === "") {
|
|
106
109
|
this.error(new Error("invalid request: missing sender"));
|
|
@@ -129,6 +132,8 @@ export class SourceTrait extends ServiceTrait {
|
|
|
129
132
|
}
|
|
130
133
|
this.sourceControllers.set(requestId, abortController);
|
|
131
134
|
reqSpool.roll(() => { this.sourceControllers.delete(requestId); });
|
|
135
|
+
this.sourceSpools.set(requestId, reqSpool);
|
|
136
|
+
reqSpool.roll(() => { this.sourceSpools.delete(requestId); });
|
|
132
137
|
/* provide info object */
|
|
133
138
|
const info = { sender, signal: abortSignal };
|
|
134
139
|
if (receiver)
|
|
@@ -229,7 +234,7 @@ export class SourceTrait extends ServiceTrait {
|
|
|
229
234
|
this.onResponse.delete(`source-fetch-credit:${requestId}`);
|
|
230
235
|
});
|
|
231
236
|
/* check for authentication */
|
|
232
|
-
if (auth)
|
|
237
|
+
if (auth !== undefined)
|
|
233
238
|
info.authenticated = await this.authenticated(sender, request.auth, auth, `source "${name}"`);
|
|
234
239
|
/* finally call the handler callback */
|
|
235
240
|
const callbackPromise = Promise.resolve(callback(...params, info));
|
|
@@ -438,6 +443,9 @@ export class SourceTrait extends ServiceTrait {
|
|
|
438
443
|
metaReject(error);
|
|
439
444
|
stream.destroy(error);
|
|
440
445
|
};
|
|
446
|
+
/* register abort handler at instance level (for destroy) */
|
|
447
|
+
this.fetchAborts.set(requestId, endWithError);
|
|
448
|
+
spool.roll(() => { this.fetchAborts.delete(requestId); });
|
|
441
449
|
/* ensure resources are released if consumer aborts stream early */
|
|
442
450
|
let cancelled = false;
|
|
443
451
|
const cancelAndUnroll = (reason) => {
|
|
@@ -110,11 +110,16 @@ class RefCountedSubscription {
|
|
|
110
110
|
return pending;
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
-
/* unsubscribe from a topic (reference-counted)
|
|
113
|
+
/* unsubscribe from a topic (reference-counted)
|
|
114
|
+
NOTICE: shared subscriptions ("$share/...") are never lingered, as a
|
|
115
|
+
lingering group member would still receive its round-robin share of
|
|
116
|
+
the group's messages and silently drop them, and would additionally
|
|
117
|
+
cause duplicate deliveries once an overlapping non-shared
|
|
118
|
+
subscription is established on the same connection */
|
|
114
119
|
async unsubscribe(topic) {
|
|
115
120
|
const count = this.decrementCount(topic);
|
|
116
121
|
if (count === 0) {
|
|
117
|
-
if (this.lingerMs > 0) {
|
|
122
|
+
if (this.lingerMs > 0 && !topic.startsWith("$share/")) {
|
|
118
123
|
/* defer the actual broker unsubscription */
|
|
119
124
|
const timer = setTimeout(() => {
|
|
120
125
|
this.lingers.delete(topic);
|
|
@@ -76,7 +76,8 @@ export class TimerTrait extends SubscriptionTrait {
|
|
|
76
76
|
ac.abort();
|
|
77
77
|
resolve();
|
|
78
78
|
}, durationMs);
|
|
79
|
-
timer.unref
|
|
79
|
+
if (typeof timer.unref === "function")
|
|
80
|
+
timer.unref();
|
|
80
81
|
if (signal !== undefined) {
|
|
81
82
|
if (ac !== undefined)
|
|
82
83
|
signal.addEventListener("abort", () => {
|
|
@@ -101,7 +102,8 @@ export class TimerTrait extends SubscriptionTrait {
|
|
|
101
102
|
ac.abort();
|
|
102
103
|
reject(new Error(info));
|
|
103
104
|
}, durationMs);
|
|
104
|
-
timer.unref
|
|
105
|
+
if (typeof timer.unref === "function")
|
|
106
|
+
timer.unref();
|
|
105
107
|
if (signal !== undefined) {
|
|
106
108
|
if (ac !== undefined)
|
|
107
109
|
signal.addEventListener("abort", () => {
|