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.
Files changed (37) hide show
  1. package/.ase/service.log +10381 -0
  2. package/.ase/task/TASK-P18.md +47 -0
  3. package/.ase/task/TASK-P2.md +76 -0
  4. package/.ase/task/TASK-P21.md +75 -0
  5. package/.claude/settings.local.json +7 -0
  6. package/CHANGELOG.md +18 -0
  7. package/README.md +4 -4
  8. package/dst-stage1/mqtt-plus-api.d.ts +6 -6
  9. package/dst-stage1/mqtt-plus-base.js +21 -4
  10. package/dst-stage1/mqtt-plus-codec.js +15 -6
  11. package/dst-stage1/mqtt-plus-error.js +47 -14
  12. package/dst-stage1/mqtt-plus-event.js +1 -1
  13. package/dst-stage1/mqtt-plus-service.js +1 -1
  14. package/dst-stage1/mqtt-plus-sink.js +10 -2
  15. package/dst-stage1/mqtt-plus-source.d.ts +1 -0
  16. package/dst-stage1/mqtt-plus-source.js +11 -3
  17. package/dst-stage1/mqtt-plus-subscription.js +7 -2
  18. package/dst-stage1/mqtt-plus-timer.js +4 -2
  19. package/dst-stage2/mqtt-plus.cjs.cjs +89 -35
  20. package/dst-stage2/mqtt-plus.esm.js +89 -35
  21. package/dst-stage2/mqtt-plus.umd.js +11 -11
  22. package/package.json +1 -1
  23. package/src/mqtt-plus-api.ts +5 -5
  24. package/src/mqtt-plus-base.ts +21 -4
  25. package/src/mqtt-plus-codec.ts +15 -8
  26. package/src/mqtt-plus-error.ts +52 -14
  27. package/src/mqtt-plus-event.ts +1 -1
  28. package/src/mqtt-plus-service.ts +1 -1
  29. package/src/mqtt-plus-sink.ts +12 -2
  30. package/src/mqtt-plus-source.ts +13 -3
  31. package/src/mqtt-plus-subscription.ts +7 -2
  32. package/src/mqtt-plus-timer.ts +4 -2
  33. package/tst/mqtt-plus-4-sink.spec.ts +43 -0
  34. package/tst/mqtt-plus-5-source.spec.ts +35 -0
  35. package/tst/mqtt-plus-6-misc.spec.ts +82 -5
  36. package/tst/mqtt-plus-8-run.spec.ts +107 -0
  37. 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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx tsx *)"
5
+ ]
6
+ }
7
+ }
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.000 LoC
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 a
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 an own packet format (allowing sender and
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 many more.
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: unique symbol;
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
- throw new Error("invalid codec configured");
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, _granted) => {
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
- const topicMatch = this.options.topicMatch(topic);
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, (_, value) => value instanceof Uint8Array
38
- ? { __Uint8Array: this.uint8ArrayToBase64(value) }
39
- : value);
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?.__Uint8Array === "string"
43
- ? this.base64ToUint8Array(value.__Uint8Array)
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
- runFinally(false, onfinally, description);
247
- runUnroll(false, spool);
278
+ runSettle(false, true, spool, onfinally, description);
248
279
  throw error;
249
280
  }
250
- runFinally(false, onfinally, description);
281
+ if (spool && oncleanup)
282
+ spool.roll(result, oncleanup);
283
+ runSettle(false, false, spool, onfinally, description);
251
284
  return result;
252
285
  }
253
- runFinally(false, onfinally, description);
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
- const result = await oncatch(error);
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 runFinally(true, onfinally, description);
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 runFinally(true, onfinally, description);
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
- abortController.abort(new Error("push stream timeout"));
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", () => {