homebridge-plugin-utils 1.35.0 → 2.1.0

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 (204) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  22. package/dist/cli/index.d.ts +166 -0
  23. package/dist/cli/index.js +551 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/disposable-stack.d.ts +59 -0
  32. package/dist/disposable-stack.js +155 -0
  33. package/dist/disposable-stack.js.map +1 -0
  34. package/dist/docChrome.d.ts +260 -0
  35. package/dist/docChrome.js +361 -0
  36. package/dist/docChrome.js.map +1 -0
  37. package/dist/eslint-plugin/config.d.mts +193 -0
  38. package/dist/eslint-plugin/index.d.mts +2 -0
  39. package/dist/eslint-plugin/plugin.d.mts +87 -0
  40. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  41. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  42. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  43. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  44. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  45. package/dist/featureOptions-docs.d.ts +96 -0
  46. package/dist/featureOptions-docs.js +193 -0
  47. package/dist/featureOptions-docs.js.map +1 -0
  48. package/dist/featureOptions.d.ts +674 -0
  49. package/dist/featureOptions.js +870 -0
  50. package/dist/featureOptions.js.map +1 -0
  51. package/dist/ffmpeg/codecs.d.ts +256 -72
  52. package/dist/ffmpeg/codecs.js +477 -262
  53. package/dist/ffmpeg/codecs.js.map +1 -1
  54. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  55. package/dist/ffmpeg/dgram-util.js +38 -0
  56. package/dist/ffmpeg/dgram-util.js.map +1 -0
  57. package/dist/ffmpeg/exec.d.ts +83 -64
  58. package/dist/ffmpeg/exec.js +77 -86
  59. package/dist/ffmpeg/exec.js.map +1 -1
  60. package/dist/ffmpeg/fmp4.d.ts +49 -2
  61. package/dist/ffmpeg/fmp4.js +47 -11
  62. package/dist/ffmpeg/fmp4.js.map +1 -1
  63. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  64. package/dist/ffmpeg/hap-enums.js +92 -0
  65. package/dist/ffmpeg/hap-enums.js.map +1 -0
  66. package/dist/ffmpeg/index.d.ts +16 -9
  67. package/dist/ffmpeg/index.js +6 -0
  68. package/dist/ffmpeg/index.js.map +1 -1
  69. package/dist/ffmpeg/mp4-assembler.d.ts +161 -0
  70. package/dist/ffmpeg/mp4-assembler.js +424 -0
  71. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  72. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  73. package/dist/ffmpeg/mp4-parser.js +130 -0
  74. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  75. package/dist/ffmpeg/options.d.ts +62 -149
  76. package/dist/ffmpeg/options.js +608 -499
  77. package/dist/ffmpeg/options.js.map +1 -1
  78. package/dist/ffmpeg/process.d.ts +142 -96
  79. package/dist/ffmpeg/process.js +406 -278
  80. package/dist/ffmpeg/process.js.map +1 -1
  81. package/dist/ffmpeg/record.d.ts +325 -186
  82. package/dist/ffmpeg/record.js +418 -565
  83. package/dist/ffmpeg/record.js.map +1 -1
  84. package/dist/ffmpeg/recording-process-double.d.ts +157 -0
  85. package/dist/ffmpeg/recording-process-double.js +190 -0
  86. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  87. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  88. package/dist/ffmpeg/rtp-parser.js +77 -0
  89. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  90. package/dist/ffmpeg/rtp.d.ts +198 -141
  91. package/dist/ffmpeg/rtp.js +474 -251
  92. package/dist/ffmpeg/rtp.js.map +1 -1
  93. package/dist/ffmpeg/settings.d.ts +5 -2
  94. package/dist/ffmpeg/settings.js +20 -5
  95. package/dist/ffmpeg/settings.js.map +1 -1
  96. package/dist/ffmpeg/stream.d.ts +57 -107
  97. package/dist/ffmpeg/stream.js +121 -150
  98. package/dist/ffmpeg/stream.js.map +1 -1
  99. package/dist/formatters.d.ts +106 -0
  100. package/dist/formatters.js +174 -0
  101. package/dist/formatters.js.map +1 -0
  102. package/dist/homebridge-enums.d.ts +30 -0
  103. package/dist/homebridge-enums.js +17 -0
  104. package/dist/homebridge-enums.js.map +1 -0
  105. package/dist/index.d.ts +13 -6
  106. package/dist/index.js +8 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logclient/auth.d.ts +114 -0
  109. package/dist/logclient/auth.js +199 -0
  110. package/dist/logclient/auth.js.map +1 -0
  111. package/dist/logclient/cli-run.d.ts +76 -0
  112. package/dist/logclient/cli-run.js +639 -0
  113. package/dist/logclient/cli-run.js.map +1 -0
  114. package/dist/logclient/cli.d.ts +3 -0
  115. package/dist/logclient/cli.js +97 -0
  116. package/dist/logclient/cli.js.map +1 -0
  117. package/dist/logclient/client.d.ts +145 -0
  118. package/dist/logclient/client.js +600 -0
  119. package/dist/logclient/client.js.map +1 -0
  120. package/dist/logclient/config.d.ts +173 -0
  121. package/dist/logclient/config.js +199 -0
  122. package/dist/logclient/config.js.map +1 -0
  123. package/dist/logclient/endpoints.d.ts +54 -0
  124. package/dist/logclient/endpoints.js +73 -0
  125. package/dist/logclient/endpoints.js.map +1 -0
  126. package/dist/logclient/filter.d.ts +45 -0
  127. package/dist/logclient/filter.js +51 -0
  128. package/dist/logclient/filter.js.map +1 -0
  129. package/dist/logclient/frame.d.ts +93 -0
  130. package/dist/logclient/frame.js +203 -0
  131. package/dist/logclient/frame.js.map +1 -0
  132. package/dist/logclient/index.d.ts +31 -0
  133. package/dist/logclient/index.js +12 -0
  134. package/dist/logclient/index.js.map +1 -0
  135. package/dist/logclient/parser.d.ts +211 -0
  136. package/dist/logclient/parser.js +393 -0
  137. package/dist/logclient/parser.js.map +1 -0
  138. package/dist/logclient/rest.d.ts +41 -0
  139. package/dist/logclient/rest.js +111 -0
  140. package/dist/logclient/rest.js.map +1 -0
  141. package/dist/logclient/settings.d.ts +15 -0
  142. package/dist/logclient/settings.js +64 -0
  143. package/dist/logclient/settings.js.map +1 -0
  144. package/dist/logclient/socket-double.d.ts +201 -0
  145. package/dist/logclient/socket-double.js +384 -0
  146. package/dist/logclient/socket-double.js.map +1 -0
  147. package/dist/logclient/socket.d.ts +257 -0
  148. package/dist/logclient/socket.js +620 -0
  149. package/dist/logclient/socket.js.map +1 -0
  150. package/dist/logclient/stitch.d.ts +83 -0
  151. package/dist/logclient/stitch.js +146 -0
  152. package/dist/logclient/stitch.js.map +1 -0
  153. package/dist/logclient/time-expression.d.ts +42 -0
  154. package/dist/logclient/time-expression.js +181 -0
  155. package/dist/logclient/time-expression.js.map +1 -0
  156. package/dist/logclient/time-window.d.ts +38 -0
  157. package/dist/logclient/time-window.js +53 -0
  158. package/dist/logclient/time-window.js.map +1 -0
  159. package/dist/logclient/types.d.ts +107 -0
  160. package/dist/logclient/types.js +6 -0
  161. package/dist/logclient/types.js.map +1 -0
  162. package/dist/mqttClient.d.ts +287 -0
  163. package/dist/mqttClient.js +433 -0
  164. package/dist/mqttClient.js.map +1 -0
  165. package/dist/service.d.ts +64 -15
  166. package/dist/service.js +93 -66
  167. package/dist/service.js.map +1 -1
  168. package/dist/ui/featureOptions.js +870 -0
  169. package/dist/ui/featureOptions.js.map +1 -0
  170. package/dist/ui/formatters.js +174 -0
  171. package/dist/ui/formatters.js.map +1 -0
  172. package/dist/ui/pluginConfigSession.mjs +141 -0
  173. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  174. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  175. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  176. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  177. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  179. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  180. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  181. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  182. package/dist/ui/webUi-featureOptions/utils.mjs +213 -0
  183. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  184. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  185. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  186. package/dist/ui/webUi-featureOptions/views/nav.mjs +341 -0
  187. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  188. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  189. package/dist/ui/webUi-featureOptions.mjs +702 -0
  190. package/dist/ui/webUi.mjs +225 -88
  191. package/dist/util.d.ts +679 -45
  192. package/dist/util.js +830 -77
  193. package/dist/util.js.map +1 -1
  194. package/package.json +33 -15
  195. package/build/eslint-rules.mjs +0 -511
  196. package/dist/featureoptions.d.ts +0 -264
  197. package/dist/featureoptions.js +0 -480
  198. package/dist/featureoptions.js.map +0 -1
  199. package/dist/mqttclient.d.ts +0 -178
  200. package/dist/mqttclient.js +0 -310
  201. package/dist/mqttclient.js.map +0 -1
  202. package/dist/ui/featureoptions.js +0 -480
  203. package/dist/ui/featureoptions.js.map +0 -1
  204. package/dist/ui/webUi-featureoptions.mjs +0 -3765
package/dist/util.js CHANGED
@@ -2,138 +2,891 @@
2
2
  *
3
3
  * util.ts: Useful utility functions when writing TypeScript.
4
4
  */
5
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
6
+ if (value !== null && value !== void 0) {
7
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
8
+ var dispose, inner;
9
+ if (async) {
10
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
11
+ dispose = value[Symbol.asyncDispose];
12
+ }
13
+ if (dispose === void 0) {
14
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
15
+ dispose = value[Symbol.dispose];
16
+ if (async) inner = dispose;
17
+ }
18
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
19
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
20
+ env.stack.push({ value: value, dispose: dispose, async: async });
21
+ }
22
+ else if (async) {
23
+ env.stack.push({ async: true });
24
+ }
25
+ return value;
26
+ };
27
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
28
+ return function (env) {
29
+ function fail(e) {
30
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
31
+ env.hasError = true;
32
+ }
33
+ var r, s = 0;
34
+ function next() {
35
+ while (r = env.stack.pop()) {
36
+ try {
37
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
38
+ if (r.dispose) {
39
+ var result = r.dispose.call(r.value);
40
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
41
+ }
42
+ else s |= 1;
43
+ }
44
+ catch (e) {
45
+ fail(e);
46
+ }
47
+ }
48
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
49
+ if (env.hasError) throw env.error;
50
+ }
51
+ return next();
52
+ };
53
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
54
+ var e = new Error(message);
55
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
56
+ });
57
+ import { setTimeout as delay } from "node:timers/promises";
58
+ // Validates a name against HomeKit's naming conventions. Compiled once at module scope since this sits on the fast path of sanitizeName().
59
+ const VALID_HOMEKIT_NAME = /^(?!.*\p{Extended_Pictographic})(?!.* {2})(?=^[\p{L}\p{N}].*[\p{L}\p{N}.]$)[\p{L}\p{N}\-"'.,#& ]+$/u;
60
+ // A sentinel AbortSignal that never aborts. Helpers that accept an optional caller signal fall back to this so internal code can always address a non-nullable signal,
61
+ // without branching for the "no signal supplied" case on every operation. Shared module-scope constant rather than per-call allocation - the underlying platform
62
+ // AbortController that produced it is unreferenced after module init, leaving the signal in a permanent unaborted state for the module's lifetime.
63
+ const NEVER_ABORTED_SIGNAL = new AbortController().signal;
64
+ // Shared no-op reaction used by {@link markHandled} to mark promises as observed. Module-scope constant keeps the identity stable across call sites so attaching the
65
+ // reaction is a single function-reference pass rather than a fresh closure allocation per call.
66
+ const MARK_HANDLED_NOOP = () => { };
67
+ // Shared no-op `Disposable` returned by {@link onAbort} on the pre-aborted branch (where no listener was registered and there is nothing to remove). Hoisted to
68
+ // module scope so every pre-aborted call reuses one instance instead of allocating a fresh object + arrow pair - matching the established pattern for other shared
69
+ // module-scope constants in this file (`NEVER_ABORTED_SIGNAL`, `MARK_HANDLED_NOOP`). Safe to share because the disposer is stateless, repeatable, and side-effect
70
+ // free: `[Symbol.dispose]()` can be invoked any number of times from any call site without interference.
71
+ const NO_OP_DISPOSABLE = { [Symbol.dispose]: () => { } };
5
72
  /**
6
- * TypeScript Utilities.
73
+ * The canonical abort error used across `homebridge-plugin-utils`.
74
+ *
75
+ * `HbpuAbortError` is a lightweight subclass of `Error` whose `name` field is one of the values in {@link HbpuAbortReason}. It is the value passed to
76
+ * `AbortController.abort(reason)` by every HBPU-owned resource class and is surfaced back to callers as a signal's `reason` or as the rejection of any HBPU-awaited
77
+ * promise that ends because of an abort.
78
+ *
79
+ * @remarks The base class is intentionally minimal. Domain-specific context (FFmpeg exit code, MQTT packet id, etc.) travels on `cause` as a structured object rather
80
+ * than as additional fields on this class, so that every consumer that catches an `HbpuAbortError` reads the same shape. Specialized subclasses (e.g.,
81
+ * `FfmpegAbortError` carrying typed exit context) may be introduced later when there is a concrete need - not preemptively.
82
+ *
83
+ * @example
84
+ *
85
+ * ```ts
86
+ * import { HbpuAbortError, isHbpuAbortReason } from "homebridge-plugin-utils";
7
87
  *
8
- * @module
88
+ * try {
89
+ *
90
+ * await recording.segments().next();
91
+ * } catch(error: unknown) {
92
+ *
93
+ * if(isHbpuAbortReason(error, "replaced")) {
94
+ *
95
+ * // Stream was superseded; this is expected during a livestream discontinuity.
96
+ * return;
97
+ * }
98
+ *
99
+ * throw error;
100
+ * }
101
+ * ```
102
+ *
103
+ * @category Utilities
9
104
  */
10
- // Validates a name against HomeKit's naming conventions. Compiled once at module scope since this sits on the fast path of sanitizeName().
11
- const VALID_HOMEKIT_NAME = /^(?!.*\p{Extended_Pictographic})(?!.* {2})(?=^[\p{L}\p{N}].*[\p{L}\p{N}.]$)[\p{L}\p{N}\-"'.,#& ]+$/u;
105
+ export class HbpuAbortError extends Error {
106
+ /**
107
+ * The tag. Matches one of {@link HbpuAbortReason}.
108
+ */
109
+ name;
110
+ /**
111
+ * Construct a new `HbpuAbortError`.
112
+ *
113
+ * @param reason - The abort reason (also assigned to `.name`).
114
+ * @param options - Optional `cause` for structured diagnostic context, and an optional human-readable `message`.
115
+ */
116
+ constructor(reason, options = {}) {
117
+ super(options.message ?? reason, { cause: options.cause });
118
+ this.name = reason;
119
+ }
120
+ }
121
+ /**
122
+ * Type guard: returns `true` if `error` is an {@link HbpuAbortError}.
123
+ *
124
+ * Use this to distinguish HBPU's canonical abort errors from arbitrary thrown values, without nesting `instanceof` checks.
125
+ *
126
+ * @param error - The value to test.
127
+ *
128
+ * @returns `true` if `error` is an `HbpuAbortError` instance.
129
+ *
130
+ * @category Utilities
131
+ */
132
+ export function isHbpuAbortError(error) {
133
+ return error instanceof HbpuAbortError;
134
+ }
135
+ /**
136
+ * Convenience type predicate: returns `true` if `error` is an {@link HbpuAbortError} whose `.name` matches `reason`, and narrows the type so callers can read
137
+ * `error.cause` and related fields without further casts.
138
+ *
139
+ * Collapses the common "is this an HBPU abort, and was it this specific reason?" question into a single call, avoiding the `instanceof` + `.name` nesting that appears
140
+ * throughout consuming code. The generic parameter `R` preserves the specific reason string in the narrowed type so callers that distinguish further by name get the
141
+ * literal narrowed form automatically.
142
+ *
143
+ * @typeParam R - The specific reason being matched. Defaulted by inference from `reason`.
144
+ * @param error - The value to test.
145
+ * @param reason - The abort reason to match.
146
+ *
147
+ * @returns `true` if `error` is an `HbpuAbortError` with the given reason.
148
+ *
149
+ * @category Utilities
150
+ */
151
+ export function isHbpuAbortReason(error, reason) {
152
+ return isHbpuAbortError(error) && (error.name === reason);
153
+ }
154
+ /**
155
+ * Test whether an abort reason indicates a timeout. Matches both the canonical {@link HbpuAbortError} with `"timeout"` name - produced by project watchdogs
156
+ * ({@link Watchdog}, the inactivity monitors on `FfmpegProcess` / `RtpDemuxer` / `Mp4SegmentAssembler`) - and the platform {@link DOMException}/`Error` whose
157
+ * `.name === "TimeoutError"` - produced by `AbortSignal.timeout()`. Consumers branch on a single predicate regardless of which code path originated the timeout.
158
+ *
159
+ * Exists because every long-lived resource class exposes an `isTimedOut` getter with identical branching logic; routing all of them through this single predicate
160
+ * enforces one taxonomy and eliminates drift if the project ever needs to add, say, a third timeout shape (e.g., an upstream-framework cancellation).
161
+ *
162
+ * @param reason - Any value found on `AbortSignal.reason`. Plain objects, non-errors, and `undefined` all return `false`.
163
+ *
164
+ * @returns `true` when the reason is a timeout in either supported shape.
165
+ *
166
+ * @category Utilities
167
+ */
168
+ export function isTimeoutReason(reason) {
169
+ if (isHbpuAbortReason(reason, "timeout")) {
170
+ return true;
171
+ }
172
+ // Platform `TimeoutError` from `AbortSignal.timeout()` - a `DOMException` (extends `Error` in Node) whose `.name === "TimeoutError"`. The `instanceof Error` guard
173
+ // excludes bare objects that happen to carry a coincidental `.name` field.
174
+ return (reason instanceof Error) && (reason.name === "TimeoutError");
175
+ }
176
+ /**
177
+ * Register a one-shot abort handler on `signal` and return a {@link Disposable} whose `[Symbol.dispose]` removes the listener. If `signal` is already aborted at
178
+ * call time, `handler` runs inline and the returned handle is a no-op disposer.
179
+ *
180
+ * Closes the well-known pitfall in `AbortSignal.addEventListener("abort", ...)`: listeners attached to an already-aborted signal **do not fire**, so constructors
181
+ * that take a parent signal and attach teardown logic via `addEventListener` silently skip that teardown when the parent is pre-aborted. This helper unifies the
182
+ * register-or-dispatch-immediately shape so every caller handles both cases without re-implementing the check.
183
+ *
184
+ * Returning a `Disposable` serves two patterns through one primitive:
185
+ *
186
+ * - **Long-lived resource-class registrations** (the common case): every HBPU resource class registers its `#teardown` handler in its constructor, intending the
187
+ * listener to live until the composed signal aborts. These callers discard the return value; the `{ once: true }` listener auto-unregisters on fire.
188
+ * - **Scope-bound transient registrations**: observers that only need the listener for a bounded scope (e.g., {@link waitWithSignal}) capture the handle with
189
+ * `using` so the listener is deterministically removed on scope exit even when the promise resolves before the signal aborts. This prevents listener accumulation
190
+ * on long-lived signals that see many short waits.
191
+ *
192
+ * The handler runs at most once: on normal abort, via the `{ once: true }` option on `addEventListener`; on pre-aborted signals, via a direct call here. The caller
193
+ * still decides what to do with the rest of its setup - a constructor that wants to short-circuit further initialization after a pre-aborted signal typically pairs
194
+ * this call with a subsequent `if(signal.aborted) return;` check.
195
+ *
196
+ * @param signal - The abort signal to observe.
197
+ * @param handler - The teardown or cleanup action to run once on abort. Invoked synchronously when `signal.aborted` is already `true` at call time; otherwise
198
+ * attached as a one-shot `"abort"` listener.
199
+ *
200
+ * @returns A {@link Disposable} handle. `[Symbol.dispose]` removes the abort listener (no-op on the pre-aborted path and after the listener has already fired).
201
+ *
202
+ * @example
203
+ *
204
+ * ```ts
205
+ * // Long-lived resource-class registration: discard the returned disposer. The listener lives until the composed signal aborts and `{ once: true }` cleans it up.
206
+ * constructor(init: { signal?: AbortSignal }) {
207
+ *
208
+ * this.signal = composeSignals(init.signal, this.#controller.signal);
209
+ *
210
+ * onAbort(this.signal, () => this.#teardown());
211
+ *
212
+ * if(this.signal.aborted) {
213
+ *
214
+ * return;
215
+ * }
216
+ *
217
+ * // ...proceed with setup that only makes sense on a live signal.
218
+ * }
219
+ * ```
220
+ *
221
+ * @example
222
+ *
223
+ * ```ts
224
+ * // Scope-bound transient registration: capture the handle with `using` so the listener auto-removes when the scope exits, even if the signal never aborts.
225
+ * async function abortableWait<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
226
+ *
227
+ * using _registration = onAbort(signal, () => {
228
+ * // Abort-driven action goes here.
229
+ * });
230
+ *
231
+ * // `return await promise` (not a bare `return promise`) is required inside an async function. `using` disposes when the enclosing function body finishes
232
+ * // executing, and without an `await` the body finishes synchronously at the `return` statement - even though the returned promise is still pending. The
233
+ * // listener would therefore be removed the instant the function returned, well before the promise settles. Adding `await` creates a suspension point that
234
+ * // keeps the `using` scope alive until the promise actually settles, which is what the "scope-bound registration" pattern relies on.
235
+ * return await promise;
236
+ * }
237
+ * ```
238
+ *
239
+ * @category Utilities
240
+ */
241
+ export function onAbort(signal, handler) {
242
+ if (signal.aborted) {
243
+ handler();
244
+ // Pre-aborted path never registered a listener, so there is nothing to remove. Return the module-scope {@link NO_OP_DISPOSABLE} singleton so the caller's `using`
245
+ // declaration compiles cleanly and explicit `[Symbol.dispose]()` invocations remain a safe no-op, without allocating a fresh object + arrow pair per call.
246
+ return NO_OP_DISPOSABLE;
247
+ }
248
+ signal.addEventListener("abort", handler, { once: true });
249
+ // The returned handle lets scope-bound observers remove themselves deterministically when the scope exits, preventing listener accumulation on long-lived signals
250
+ // that serve many short-lived waits. `removeEventListener` does nothing - calling it after the listener has already fired (and been auto-removed by `{ once: true }`)
251
+ // is a safe no-op.
252
+ return { [Symbol.dispose]: () => signal.removeEventListener("abort", handler) };
253
+ }
254
+ /**
255
+ * Attach a shared no-op rejection handler to `promise` so that if it rejects and no other observer is attached, Node does not emit an `UnhandledPromiseRejection`
256
+ * warning. Returns the original promise so callers can mark-and-assign in one expression.
257
+ *
258
+ * Use this on internal promise handles (`ready`, `exited`, init segments) that a class exposes for callers who may or may not choose to observe them. Callers who
259
+ * `await` the promise or attach their own `.catch` still see the rejection through their own chain - this helper only marks the promise as observed for Node's
260
+ * unhandled-rejection tracker.
261
+ *
262
+ * @typeParam T - The resolved value type.
263
+ * @param promise - The promise to mark handled.
264
+ *
265
+ * @returns The same promise, for chained assignment.
266
+ *
267
+ * @example
268
+ *
269
+ * ```ts
270
+ * this.ready = markHandled(readyResolvers.promise);
271
+ * ```
272
+ *
273
+ * @category Utilities
274
+ */
275
+ // Identity-preserving helper: returns the caller's promise unchanged so mark-and-assign flows (`this.ready = markHandled(...)`) keep reference equality with the
276
+ // underlying resolver. Marking this `async` would wrap the return in a fresh promise chain and break that contract.
277
+ export function markHandled(promise) {
278
+ promise.catch(MARK_HANDLED_NOOP);
279
+ return promise;
280
+ }
281
+ /**
282
+ * A shippable no-op {@link HomebridgePluginLogging}: every method accepts the logging signature and discards its arguments. A module-scope singleton - the methods are
283
+ * stateless and side-effect-free, so one shared instance is safe to reuse everywhere - which keeps the omitted-logger path allocation-free. This is the SSOT no-op
284
+ * logger: callers that need a CONCRETE logger but want no output default to it (e.g. a subsystem whose lower layer requires a non-optional logger), and the test-only
285
+ * `silentLog` helper derives from it rather than re-declaring the empty sink.
286
+ *
287
+ * @category Utilities
288
+ */
289
+ export const noOpLog = {
290
+ debug: () => { },
291
+ error: () => { },
292
+ info: () => { },
293
+ warn: () => { }
294
+ };
295
+ // Re-export the magnitude-and-percentage formatters from the browser-safe `formatters.ts` module. `featureOptions.ts` imports them directly from there (so it can
296
+ // ship into `dist/ui/` without dragging in any of util.ts's Node-only imports); util.ts surfaces them here so server-side consumers see the same public API they
297
+ // always did. The single SSOT is `formatters.ts`; this file is just a forwarding seam.
298
+ export { formatBps, formatBytes, formatMs, formatPercent, formatSeconds } from "./formatters.js";
299
+ /**
300
+ * Render an arbitrary thrown value as a clean log-suffix string. Real `Error` instances surface their `.message`; everything else is coerced through `String(...)`.
301
+ * A trailing period is stripped in either case so the embedding log line (which itself ends in a period) does not produce ".." at the end of the rendered output.
302
+ *
303
+ * @param error - The thrown value, typically caught from a `try` block or rejected Promise.
304
+ *
305
+ * @returns The cleaned message ready to interpolate into a log format string.
306
+ *
307
+ * @example
308
+ *
309
+ * ```ts
310
+ * try {
311
+ *
312
+ * await someOperation();
313
+ * } catch(error) {
314
+ *
315
+ * log.error("Operation failed: %s.", formatErrorMessage(error));
316
+ * }
317
+ * ```
318
+ *
319
+ * @category Utilities
320
+ */
321
+ export function formatErrorMessage(error) {
322
+ return ((error instanceof Error) ? error.message : String(error)).replace(/\.$/, "");
323
+ }
324
+ /**
325
+ * The default backoff policy used by {@link retry}: exponential with a 30-second ceiling, starting at 1 second for the second attempt (`attempt = 2`).
326
+ *
327
+ * @param attempt - The attempt number about to be run (1-indexed; never called with `attempt === 1`, since the first attempt runs immediately).
328
+ *
329
+ * @returns The delay, in milliseconds, to wait before executing `attempt`.
330
+ *
331
+ * @category Utilities
332
+ */
333
+ export function defaultRetryBackoff(attempt) {
334
+ return Math.min(30_000, 1_000 * (2 ** (attempt - 2)));
335
+ }
12
336
  /**
13
- * A utility method that formats a bitrate value into a human-readable form as kbps or Mbps.
337
+ * Retry an async operation with configurable attempts and backoff, with first-class abort signal support.
338
+ *
339
+ * The operation receives the caller's {@link AbortSignal} directly (or a permanent never-aborted sentinel when no caller signal was provided). Well-behaved operations
340
+ * forward this signal to any cancellation-aware API they call (`fetch`, `events.once`, etc.) so the in-flight attempt actually cancels. Between-attempt waits use
341
+ * `node:timers/promises` `setTimeout` with the signal, so abort also interrupts the backoff.
342
+ *
343
+ * @typeParam T - The successful resolution type of `operation`.
344
+ * @param operation - The async work to perform. Receives the composed abort signal; must resolve with a value on success, or throw/reject on failure.
345
+ * @param options - Retry options. See {@link RetryOptions}.
14
346
  *
15
- * @param value - The bitrate value to convert.
347
+ * @returns Resolves with the first successful operation result. Rejects with the operation's error once the attempt budget is exhausted or a `shouldRetry` predicate
348
+ * vetoes a further attempt, or with the signal's reason if aborted mid-attempt or mid-backoff.
16
349
  *
17
- * @returns Returns the value as a human-readable string.
18
350
  * @example
19
351
  *
20
352
  * ```ts
21
- * formatBps(500); // "500 bps".
22
- * formatBps(2000); // "2.0 kbps".
23
- * formatBps(15000); // "15.0 kbps".
24
- * formatBps(1000000); // "1.0 Mbps".
25
- * formatBps(2560000); // "2.6 Mbps".
353
+ * import { retry } from "homebridge-plugin-utils";
354
+ *
355
+ * const controller = new AbortController();
356
+ *
357
+ * const device = await retry(async (signal) => fetchDevice(id, { signal }), {
358
+ *
359
+ * attempts: 5,
360
+ * backoff: (attempt) => 1_000 * attempt,
361
+ * signal: controller.signal
362
+ * });
26
363
  * ```
364
+ *
365
+ * @category Utilities
27
366
  */
28
- export function formatBps(value) {
29
- // Return the bitrate as-is.
30
- if (value < 1000) {
31
- return value.toString() + " bps";
367
+ export async function retry(operation, options = {}) {
368
+ const { attempts = 3, backoff = defaultRetryBackoff, shouldRetry, signal } = options;
369
+ // Reject nonsensical attempt counts at the library boundary so the "attempts >= 1" guarantee the loop relies on is enforced rather than latent.
370
+ if (attempts < 1) {
371
+ throw new Error("retry: `attempts` must be >= 1.");
372
+ }
373
+ // Honor a pre-aborted caller signal immediately - avoids spawning the first attempt just to tear it down.
374
+ signal?.throwIfAborted();
375
+ // When the caller provides no signal, fall back to the module-scope never-aborted sentinel so `operation` always receives a non-nullable `AbortSignal`. Avoids
376
+ // branching inside the hot path each attempt and a per-call controller allocation.
377
+ const operationSignal = signal ?? NEVER_ABORTED_SIGNAL;
378
+ // Run each attempt, yielding to the backoff policy between failures. The caller's signal is the single source of truth for cancellation: the outer catch below is the
379
+ // single normalizer that translates any rejection that coincides with an aborted signal back into `signal.reason`. Each abortable await inside the loop - today the
380
+ // operation and the backoff wait, tomorrow anything we add - inherits that normalization without owing its own check.
381
+ try {
382
+ for (let attempt = 1; attempt <= attempts; attempt++) {
383
+ try {
384
+ // eslint-disable-next-line no-await-in-loop
385
+ return await operation(operationSignal);
386
+ }
387
+ catch (error) {
388
+ // Stop and surface the operation's last error when the attempt budget is exhausted, or when `shouldRetry` vetoes another attempt for this error. The budget
389
+ // check comes first so an exhausted budget always rethrows and the predicate is consulted only while attempts remain; a `false` verdict rethrows immediately
390
+ // with no backoff wait. When `shouldRetry` is omitted the optional call yields `undefined` (not `false`), so every error retries until the budget runs out -
391
+ // the existing behavior. If the caller also aborted here, the outer catch normalizes to the signal's reason.
392
+ if ((attempt === attempts) || (shouldRetry?.(error, attempt) === false)) {
393
+ throw error;
394
+ }
395
+ // Wait the policy-dictated delay before the next attempt. If the caller aborts mid-wait, `node:timers/promises` `setTimeout` rejects with a platform
396
+ // `AbortError` that does NOT carry `signal.reason`; the outer catch consults the signal itself and restores the caller's reason for us.
397
+ // eslint-disable-next-line no-await-in-loop
398
+ await delay(backoff(attempt + 1), undefined, { signal });
399
+ }
400
+ }
401
+ // Unreachable when `attempts >= 1`: the loop either returns the operation result or rethrows the last attempt's error. TypeScript needs a terminal statement.
402
+ throw new Error("retry: unreachable after attempt loop");
32
403
  }
33
- // Return the bitrate in kilobits.
34
- if (value < 1000000) {
35
- const kbps = value / 1000;
36
- return ((kbps % 1) === 0 ? kbps.toFixed(0) : kbps.toFixed(1)) + " kbps";
404
+ catch (error) {
405
+ // Single source of truth for cancellation. Whatever rejection bubbled out of the loop - operation error, backoff-wait `AbortError`, or anything we add later - if
406
+ // the caller's signal is aborted, the signal's reason is the canonical answer. `throwIfAborted()` is a no-op when the signal is not aborted, so genuine errors
407
+ // propagate unchanged.
408
+ signal?.throwIfAborted();
409
+ throw error;
37
410
  }
38
- // Return the bitrate in megabits.
39
- const mbps = value / 1000000;
40
- return ((mbps % 1) === 0 ? mbps.toFixed(0) : mbps.toFixed(1)) + " Mbps";
41
411
  }
42
412
  /**
43
- * A utility method that retries an operation at a specific interval for up to an absolute total number of retries.
413
+ * Wait for `promise` to settle, bailing out early if `signal` aborts before it does.
414
+ *
415
+ * The canonical primitive for "observe this promise but let a caller cancel the wait." Useful inside async flows that reference an external promise (e.g., a resource
416
+ * class's internal state) and need to honor a per-call abort signal without modifying the underlying promise. Whichever settles first wins: `promise` resolves/rejects
417
+ * normally, or the signal aborts and `waitWithSignal` rejects with `signal.reason` - including when the signal was already aborted at call time.
418
+ *
419
+ * The abort listener is attached with `{ once: true }` and explicitly removed when the helper settles, so there is no listener leak regardless of which side wins the
420
+ * race. `promise` is ALWAYS observed via `.then(resolve, reject)` - including on the pre-aborted-signal path - which means attaching `waitWithSignal` to a promise
421
+ * marks it as handled for Node's unhandled-rejection tracker. Callers do not need to wrap `promise` in {@link markHandled} separately.
422
+ *
423
+ * @typeParam T - The resolved value type of `promise`.
424
+ * @param promise - The promise to wait on.
425
+ * @param signal - The abort signal whose firing interrupts the wait.
426
+ *
427
+ * @returns The promise's resolved value.
428
+ *
429
+ * @throws `signal.reason` if the signal aborts before `promise` settles, or the original rejection if `promise` rejects first.
430
+ *
431
+ * @example
432
+ *
433
+ * ```ts
434
+ * import { waitWithSignal } from "homebridge-plugin-utils";
435
+ *
436
+ * try {
437
+ *
438
+ * const initSegment = await waitWithSignal(assembler.initSegment, callerSignal);
439
+ * } catch {
440
+ *
441
+ * // Caller aborted, or the assembler rejected init. Either way, unwind cleanly.
442
+ * return;
443
+ * }
444
+ * ```
445
+ *
446
+ * @category Utilities
447
+ */
448
+ export async function waitWithSignal(promise, signal) {
449
+ const env_1 = { stack: [], error: void 0, hasError: false };
450
+ try {
451
+ const { promise: result, resolve, reject } = Promise.withResolvers();
452
+ // `onAbort` is the single source of truth for "register an abort-driven action, handle the pre-aborted-signal pitfall, and release the listener when done." The
453
+ // pre-aborted path runs our handler inline (rejecting `result` immediately); the live path attaches the listener with `{ once: true }` and returns a disposer that
454
+ // we hand off to `using` so the listener is deterministically removed when this function's scope exits. That matters for long-lived signals (e.g., a plugin's
455
+ // lifetime controller) that see many short waits - without explicit removal, each call would leak a listener until the signal finally aborts.
456
+ const _abortRegistration = __addDisposableResource(env_1, onAbort(signal, () => reject(signal.reason)), false);
457
+ // `promise` is ALWAYS observed via `.then(resolve, reject)` - including on the pre-aborted path - so `waitWithSignal` marks it handled for Node's unhandled-rejection
458
+ // tracker regardless of which side wins the race. The derived `.then` microtask becomes a no-op if it loses the race (reject was already called via the abort path,
459
+ // or resolve is already settled). The derived promise itself always fulfills (our handlers return void), so discarding it with `void` is safe.
460
+ void promise.then(resolve, reject);
461
+ return await result;
462
+ }
463
+ catch (e_1) {
464
+ env_1.error = e_1;
465
+ env_1.hasError = true;
466
+ }
467
+ finally {
468
+ __disposeResources(env_1);
469
+ }
470
+ }
471
+ /**
472
+ * Drain an async iterable and retain only its last `n` values, returned in original (oldest-to-newest) order.
473
+ *
474
+ * The implementation is a true fixed-capacity ring buffer: it allocates a single backing array of length `n` once and overwrites slots modulo `n` as values arrive, so
475
+ * memory stays bounded at `n` entries no matter how long the source runs. It deliberately does NOT accumulate every value and slice the tail at the end - that naive
476
+ * shape would grow without bound on a long-running source (the canonical use here is "the last ~500 lines of a multi-MB log seed"), defeating the entire point of a
477
+ * bounded retainer. When the source yields `n` or fewer values the result is simply those values in order; when it yields more, only the most recent `n` survive.
478
+ *
479
+ * Consumption is eager and complete: the source is iterated to exhaustion before returning, so callers must only pass iterables that terminate (a finite seed window,
480
+ * not an unbounded live stream). A non-positive `n` retains nothing and returns an empty array without iterating the source at all.
481
+ *
482
+ * @typeParam T - The element type of the source.
483
+ * @param source - The async iterable to drain. Must terminate.
484
+ * @param n - The maximum number of trailing values to retain. Values `<= 0` retain nothing.
485
+ *
486
+ * @returns The last `n` values produced by `source`, in original order.
487
+ *
488
+ * @example
489
+ *
490
+ * ```ts
491
+ * import { takeLast } from "homebridge-plugin-utils";
492
+ *
493
+ * // Retain only the most recent 500 seed lines from a bounded history window, regardless of how many the source emits.
494
+ * const recent = await takeLast(seedLines, 500);
495
+ * ```
496
+ *
497
+ * @category Utilities
498
+ */
499
+ export async function takeLast(source, n) {
500
+ // A non-positive capacity retains nothing. Returning before touching the source avoids both an empty-ring allocation and any iteration side effects the caller did not
501
+ // ask to pay for.
502
+ if (n <= 0) {
503
+ return [];
504
+ }
505
+ // The single fixed-capacity backing store. We overwrite slots modulo `n` so the ring never grows beyond `n` entries even on an arbitrarily long source - the explicit
506
+ // contrast to an accumulate-then-slice approach that would retain every value transiently.
507
+ const ring = new Array(n);
508
+ // `count` is the total number of values seen; `write` is the next slot to overwrite. Tracking the raw count (rather than a saturating flag) lets us reconstruct the
509
+ // correct oldest-to-newest order at the end whether the source under- or over-filled the ring.
510
+ let count = 0;
511
+ let write = 0;
512
+ for await (const value of source) {
513
+ ring[write] = value;
514
+ write = (write + 1) % n;
515
+ count++;
516
+ }
517
+ // Under-filled: fewer values than capacity arrived, so the ring's first `count` slots already hold them in order. Slice rather than return the oversized backing array.
518
+ if (count < n) {
519
+ return ring.slice(0, count);
520
+ }
521
+ // Filled or over-filled: the oldest retained value sits at `write` (the slot we were about to overwrite next), so we read forward from there, wrapping once, to
522
+ // recover oldest-to-newest order. This single rotation is the only reordering cost, paid once at the end rather than per value.
523
+ const result = new Array(n);
524
+ for (let index = 0; index < n; index++) {
525
+ // The cast is sound here: when `count >= n` every ring slot has been written at least once, so no slot is the initial `undefined` hole `noUncheckedIndexedAccess`
526
+ // guards against. We narrow the `T | undefined` index read back to `T` with one cast rather than re-checking a value we have already proven present.
527
+ result[index] = ring[(write + index) % n];
528
+ }
529
+ return result;
530
+ }
531
+ /**
532
+ * Compose one or more optional {@link AbortSignal} sources into a single signal that aborts when any input aborts.
533
+ *
534
+ * Collapses the recurring `parent ? AbortSignal.any([ parent, internal ]) : internal` pattern into a single call, used by every resource class in this library to
535
+ * compose its lifetime signal. Filters out `undefined` inputs, returns the sole defined signal unchanged (no unnecessary `any()` wrapper), and composes two or more
536
+ * defined signals with `AbortSignal.any()`.
537
+ * Throws a {@link TypeError} when every input is `undefined`, because a class whose lifetime is defined by a signal must always have at least one concrete signal to
538
+ * compose against.
44
539
  *
45
- * @param operation - The operation callback to try until successful.
46
- * @param retryInterval - Interval to retry, in milliseconds.
47
- * @param totalRetries - Optionally, specify the total number of retries.
540
+ * @param signals - Ordered list of signal sources. `undefined` entries are filtered out; order is preserved among defined entries.
48
541
  *
49
- * @returns Returns `true` when the operation is successful, `false` otherwise or if the total number of retries has been exceeded.
542
+ * @returns The single defined signal when only one was supplied; otherwise a new signal that aborts as soon as any input aborts, carrying the first aborting input's
543
+ * reason as its own `reason`.
50
544
  *
51
- * @remarks `operation` must be an asynchronous function that returns `true` when successful, and `false` otherwise.
545
+ * @throws `TypeError` if every input is `undefined` - the caller passed no concrete signal to compose.
52
546
  *
53
547
  * @example
548
+ *
549
+ * ```ts
550
+ * // Class constructor composing an optional parent signal with the internal controller's signal.
551
+ * this.signal = composeSignals(init.signal, this.#controller.signal);
552
+ *
553
+ * // Per-call composition of the class signal with a caller-supplied per-call signal.
554
+ * const composed = composeSignals(this.signal, init.signal);
555
+ *
556
+ * // Compose an optional caller signal with a derived watchdog timeout.
557
+ * const composed = composeSignals(init.signal, AbortSignal.timeout(PROBE_DEFAULT_TIMEOUT_MS));
558
+ * ```
559
+ *
560
+ * @category Utilities
561
+ */
562
+ export function composeSignals(...signals) {
563
+ const defined = signals.filter((signal) => signal !== undefined);
564
+ const [first, ...rest] = defined;
565
+ // At least one concrete signal is required. A class whose lifetime is expressed by a signal cannot exist without one; surfacing the misuse at the boundary catches
566
+ // the mistake loudly rather than silently producing a signal that can never abort.
567
+ if (first === undefined) {
568
+ throw new TypeError("composeSignals: at least one signal must be provided.");
569
+ }
570
+ // Short-circuit when only one signal was defined. Wrapping a single signal in `AbortSignal.any([ ... ])` would allocate a derived signal with no behavioral benefit,
571
+ // so we return the input unchanged. Preserves reference equality for callers who compare against the input.
572
+ if (rest.length === 0) {
573
+ return first;
574
+ }
575
+ return AbortSignal.any(defined);
576
+ }
577
+ /**
578
+ * Supervise a detached, signal-bound async loop: run the loop, resolve quietly when it ends or its signal aborts, and route any genuine fault to a caller-supplied
579
+ * handler exactly once.
580
+ *
581
+ * Resilient background loops - membership observers, reachability probes, telemetry firehoses - all share one subtle, correctness-critical rule: a throw is a
582
+ * *fault* only when we did not cause it. When the bound signal is aborted, a throw is the orderly unwinding of a loop the caller already tore down, so it is swallowed
583
+ * silently. Any other throw is a genuine fault and is handed to `onError` exactly once. Hand-copying that swallow-on-abort-versus-surface-once distinction across
584
+ * call sites that share no ancestor is how it drifts apart; owning it in one generic primitive is how it stays consistent.
585
+ *
586
+ * The home is here, beside {@link composeSignals}, because the envelope is fully generic - it carries no logging policy, no message wording, and makes no detachment
587
+ * decision of its own. When the loops to supervise live on objects with no common base class (so the shared logic cannot be a method), this free function is the only
588
+ * shared home. The returned promise NEVER rejects as a consequence of the loop: it resolves when the loop returns (a finite source ending), when the signal aborts
589
+ * (orderly teardown, swallowed), or once a genuine fault has been delivered to `onError`. The caller owns the rest - `void` the result to fire-and-forget a detached
590
+ * loop, or `await` it for orderly shutdown and in tests.
591
+ *
592
+ * @param options - Supervision inputs.
593
+ * @param options.loop - The loop to run, once. It receives the bound {@link AbortSignal} so it can wire cancellation into `observe()` / `fetch()` / stream reads.
594
+ * @param options.onError - Invoked at most once, with the thrown value unchanged, when the loop faults while the signal is NOT aborted. It carries the caller's entire
595
+ * fault policy (logging, wording, recovery), which is why the primitive itself stays logging-free. A throw from `onError` is a defect in the
596
+ * handler and propagates - the never-rejects guarantee covers the loop, not the handler.
597
+ * @param options.signal - The signal the loop is bound to. Its aborted state is the single source of truth for "did we cause this throw?": aborted means swallow,
598
+ * not aborted means surface.
599
+ *
600
+ * @returns A promise that resolves when the loop ends, the signal aborts, or a fault has been delivered to `onError`. It does not reject for any of those outcomes.
601
+ *
602
+ * @example
603
+ *
54
604
  * ```ts
55
- * // Example: Retry an async operation up to 5 times, waiting 1 second between each try.
56
- * let attempt = 0;
57
- * const result = await retry(async () => {
605
+ * import { superviseLoop } from "homebridge-plugin-utils";
606
+ *
607
+ * // Fire-and-forget a detached observer that survives transient faults until its controller is torn down. Aborting `this.signal` unwinds the loop silently; any other
608
+ * // failure is surfaced once through the caller's own wording.
609
+ * void superviseLoop({
58
610
  *
59
- * attempt++;
611
+ * loop: async (signal) => {
60
612
  *
61
- * // Simulate a 50% chance of success
62
- * return Math.random() > 0.5 || attempt === 5;
63
- * }, 1000, 5);
613
+ * for await (const event of client.observe(selector, { signal })) {
64
614
  *
65
- * console.log(result); // true if operation succeeded within 5 tries, otherwise false.
615
+ * this.handle(event);
616
+ * }
617
+ * },
618
+ * onError: (error) => this.log.error("The membership observer stopped unexpectedly and will not restart until the next reload: %s", formatErrorMessage(error)),
619
+ * signal: this.signal
620
+ * });
66
621
  * ```
67
622
  *
68
623
  * @category Utilities
69
624
  */
70
- export async function retry(operation, retryInterval, totalRetries) {
71
- let remainingRetries = totalRetries;
72
- for (;;) {
73
- // If we've exhausted our retries, we're done.
74
- if ((remainingRetries !== undefined) && (remainingRetries <= 0)) {
75
- return false;
625
+ export async function superviseLoop(options) {
626
+ const { loop, onError, signal } = options;
627
+ // Run the loop bound to its signal. The loop forwards `signal` into whatever cancellable work it drives, so tearing the signal down unwinds the loop from the inside.
628
+ try {
629
+ await loop(signal);
630
+ }
631
+ catch (error) {
632
+ // The one rule this primitive centralizes: distinguish orderly teardown from a genuine fault. An aborted signal means this throw is the loop unwinding in
633
+ // response to a teardown we initiated, so it is expected and is swallowed silently. Otherwise the loop faulted on its own, and we surface it through `onError`
634
+ // exactly once. Either branch resolves the returned promise rather than rejecting it, so a detached caller never produces an unhandled rejection.
635
+ if (signal.aborted) {
636
+ return;
76
637
  }
77
- // Try the operation that was requested.
78
- // eslint-disable-next-line no-await-in-loop
79
- if (await operation()) {
80
- return true;
638
+ onError(error);
639
+ }
640
+ }
641
+ /**
642
+ * Build the standard {@link superviseLoop} `onError` handler: a reporter that logs a faulted supervised loop with one canonical message, rendering the thrown value
643
+ * through {@link formatErrorMessage}.
644
+ *
645
+ * `superviseLoop` is deliberately logging-free - it owns the swallow-on-abort-versus-surface-once control flow and nothing else, so the wording of what to say when a
646
+ * loop dies lives here, in an explicitly logging companion, never in the primitive itself. Plugins that supervise the same shape of loop - a client observe-loop bound
647
+ * to a terminal shutdown signal with no auto-respawn - all owe the operator the same report: the fault is terminal until the next restart, so the message says exactly
648
+ * that and hands over the one actionable hint. Single-sourcing the template and the formatting here keeps that report from being hand-copied (and quietly drifting)
649
+ * across plugins that share no ancestor - the same "no shared home, so a free function is the home" situation {@link superviseLoop} itself answers.
650
+ *
651
+ * The wording is specific to that bound-to-shutdown, no-respawn lifecycle. A consumer whose loops recover on their own - reconnecting, re-arming, respawning - has
652
+ * different news to deliver and should pass its own `onError` to {@link superviseLoop} rather than this reporter.
653
+ *
654
+ * @param log - The plugin logger the report is written to; its `error` method receives the canonical format string and arguments.
655
+ * @param label - The loop's name, interpolated as the `%s` in `"HomeKit updates for %s ..."` so anyone reading the log can tell which supervised loop died.
656
+ *
657
+ * @returns The `(error) => void` handler to hand to {@link superviseLoop}'s `onError`. It logs exactly once per fault and returns nothing.
658
+ *
659
+ * @example
660
+ *
661
+ * ```ts
662
+ * import { loopFaultReporter, superviseLoop } from "homebridge-plugin-utils";
663
+ *
664
+ * // The standard supervised observer: swallow on shutdown, and on a genuine fault log the canonical "<label> loop died, restart to recover" report exactly once.
665
+ * void superviseLoop({
666
+ *
667
+ * loop: (signal) => this.observeMembership(signal),
668
+ * onError: loopFaultReporter(this.log, "membership"),
669
+ * signal: this.signal
670
+ * });
671
+ * ```
672
+ *
673
+ * @category Utilities
674
+ */
675
+ export function loopFaultReporter(log, label) {
676
+ // Return the closure that *is* the `onError`: it logs the one canonical "supervised loop died" line, deferring error rendering to `formatErrorMessage` so the trailing
677
+ // period is normalized to one and the wording matches every other error log in the package rather than being re-derived at this call site.
678
+ return (error) => log.error("HomeKit updates for %s stopped unexpectedly and will not resume until the Homebridge plugin restarts: %s.", label, formatErrorMessage(error));
679
+ }
680
+ export function guardedDispatch(options) {
681
+ const { label, log } = options;
682
+ // The callback-less path: with no completion callback to carry a failure, run the handler and log any fault. A synchronous throw and an asynchronous rejection both
683
+ // surface through the same `await` inside the inner async function, matching the swallow-or-surface envelope `superviseLoop` uses. The `in` check tells the two arities
684
+ // apart: the callback-less member has no `callback` property at all, so it narrows here while the callback member falls through below.
685
+ if (!("callback" in options)) {
686
+ const { handler } = options;
687
+ void markHandled((async () => {
688
+ try {
689
+ await handler();
690
+ }
691
+ catch (error) {
692
+ log.error("The %s handler failed: %s.", label, formatErrorMessage(error));
693
+ }
694
+ })());
695
+ return;
696
+ }
697
+ // The callback path: wrap the real callback so it fires exactly once, forwarding whatever arguments the handler answers with. The `answered` flag is owned here, not by
698
+ // the handler, so a well-behaved answer, a double answer, and a post-answer fault all converge on a single delivery.
699
+ const { callback, handler } = options;
700
+ // The guarded callback's fire state lives on a mutable cell rather than a bare captured boolean so both the guard closure and the fault handler below read the same
701
+ // value. A bare `let` would flow-narrow to its `false` initializer at the read site - the only assignment to `true` is inside the closure - hiding the post-answer
702
+ // case we must distinguish; a property read resets that narrowing after the intervening call.
703
+ const callbackState = { answered: false };
704
+ // A fresh arrow cannot be inferred as the caller's opaque generic `C`, so the assertion restores that identity. The body honors `C`'s own parameters through
705
+ // `Parameters<C>`, so the guard forwards the handler's exact answer - an error, or an error plus a payload - to the real callback unchanged.
706
+ const answerOnce = ((...args) => {
707
+ if (callbackState.answered) {
708
+ return;
81
709
  }
82
- // If the operation wasn't successful, sleep for the requested interval and try again.
83
- if (remainingRetries !== undefined) {
84
- remainingRetries--;
710
+ callbackState.answered = true;
711
+ callback(...args);
712
+ });
713
+ void markHandled((async () => {
714
+ try {
715
+ await handler(answerOnce);
85
716
  }
86
- // eslint-disable-next-line no-await-in-loop
87
- await sleep(retryInterval);
88
- }
717
+ catch (error) {
718
+ // A fault after the callback already answered cannot change HomeKit's answer, so it is logged and the earlier answer stands. A fault before any answer is delivered
719
+ // to HomeKit through the still-open callback, the single most useful place for it to surface.
720
+ if (callbackState.answered) {
721
+ log.error("The %s handler failed after it had already responded to HomeKit: %s.", label, formatErrorMessage(error));
722
+ return;
723
+ }
724
+ answerOnce((error instanceof Error) ? error : new Error(formatErrorMessage(error)));
725
+ }
726
+ })());
89
727
  }
90
728
  /**
91
- * Run a promise with a guaranteed timeout to complete.
729
+ * Run an abortable operation with signal-based cancellation.
730
+ *
731
+ * The caller provides a factory function that receives an {@link AbortSignal}. The signal fires when the timeout expires, when the caller's own signal aborts, or
732
+ * whichever comes first when both are provided. The factory must forward this signal to any API that accepts one (`events.once`, `fetch`, Node stream methods, etc.) so
733
+ * the underlying work is actually cancelled. When the signal fires and the factory rejects, the rejection is caught and `null` is returned. Genuine (non-abort) errors
734
+ * from the factory propagate normally.
92
735
  *
93
- * @typeParam T - The type of value the promise resolves with.
94
- * @param promise - The promise you want to run.
95
- * @param timeout - The amount of time, in milliseconds, to wait for the promise to resolve.
736
+ * @typeParam T - The type of value the factory's promise resolves with.
737
+ * @param fn - A factory that receives the composed abort signal and returns the promise to await.
738
+ * @param options - Abort options. Provide `timeout` (milliseconds), an external `signal`, or both.
96
739
  *
97
- * @returns Returns the result of resolving the promise it's been passed if it completes before timeout, or null if the timeout expires. The internal timer is cleaned
98
- * up regardless of which outcome wins the race. Note that the underlying promise is not cancelled...it continues to run, but its result is ignored.
740
+ * @returns Resolves with the factory's result if it completes before abort, or `null` if the signal fires first.
99
741
  *
100
742
  * @example
101
743
  * ```ts
102
- * // Resolves in 100ms, timeout is 500ms, so it resolves to 42.
103
- * const result = await runWithTimeout(Promise.resolve(42), 500);
104
- * console.log(result); // 42
744
+ * // Timeout only - cancel after 500ms.
745
+ * const result = await runWithAbort((signal) => fetch(url, { signal }), { timeout: 500 });
105
746
  *
106
- * // Resolves in 1000ms, timeout is 500ms, so it resolves to null.
107
- * const slowPromise = new Promise<number>(resolve => setTimeout(() => resolve(42), 1000));
108
- * const result2 = await runWithTimeout(slowPromise, 500);
109
- * console.log(result2); // null
747
+ * // External signal only - cancel on demand.
748
+ * const controller = new AbortController();
749
+ * const result2 = await runWithAbort((signal) => once(emitter, "data", { signal }), { signal: controller.signal });
750
+ * controller.abort();
751
+ *
752
+ * // Both - cancel on demand or after 5 seconds, whichever comes first.
753
+ * const result3 = await runWithAbort((signal) => once(emitter, "data", { signal }), { signal: controller.signal, timeout: 5000 });
110
754
  * ```
111
755
  *
112
756
  * @category Utilities
113
757
  */
114
- export async function runWithTimeout(promise, timeout) {
115
- let timer;
116
- const timeoutPromise = new Promise((resolve) => { timer = setTimeout(() => { resolve(null); }, timeout); });
117
- return Promise.race([promise, timeoutPromise]).finally(() => { clearTimeout(timer); });
758
+ export async function runWithAbort(fn, options) {
759
+ // Route through `composeSignals` so this helper uses the same signal-composition primitive every other HBPU resource class uses. The discriminated union
760
+ // guarantees at least one of `signal` / `timeout` is defined, which means `composeSignals` always receives at least one concrete signal and never throws its
761
+ // empty-input guard. When only a timeout is supplied, `composeSignals` returns that single timeout signal unwrapped (no needless `AbortSignal.any` allocation).
762
+ const callerSignal = ("signal" in options) ? options.signal : undefined;
763
+ const timeoutSignal = (options.timeout !== undefined) ? AbortSignal.timeout(options.timeout) : undefined;
764
+ const signal = composeSignals(callerSignal, timeoutSignal);
765
+ // If the signal is already aborted, return immediately without starting any work.
766
+ if (signal.aborted) {
767
+ return null;
768
+ }
769
+ // Run the factory and let the signal handle cancellation. We check `signal.aborted` rather than inspecting the error type because the signal is the source of truth
770
+ // for cancellation state...the exception type varies by API and abort reason (AbortError from manual abort, TimeoutError from AbortSignal.timeout(), or any custom
771
+ // reason). Genuine errors that occur before the signal fires propagate normally.
772
+ try {
773
+ return await fn(signal);
774
+ }
775
+ catch (error) {
776
+ // The signal's aborted state changes asynchronously while the factory is running...the early-out check above doesn't constrain this.
777
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
778
+ if (signal.aborted) {
779
+ return null;
780
+ }
781
+ throw error;
782
+ }
118
783
  }
119
784
  /**
120
- * Emulate a sleep function.
785
+ * Re-armable inactivity watchdog.
786
+ *
787
+ * Every long-lived resource class in this library that cares about liveness - an FFmpeg stream's return-port UDP socket, the fMP4 segment assembler's inter-segment
788
+ * pacing, the RTP demuxer's inbound-packet cadence - composes a single `Watchdog` instance to implement the shared "abort if no activity within window" pattern.
789
+ *
790
+ * The semantics are minimal on purpose:
121
791
  *
122
- * @param sleepTimer - The amount of time to sleep, in milliseconds.
792
+ * - `arm()` starts the window. If a previous arm is still pending, it is replaced; if the observed signal has already aborted or the watchdog has been disposed, the
793
+ * call is a no-op.
794
+ * - If nothing calls `arm()` again within `timeoutMs`, `onFire` runs - but only if the signal is still unaborted at that instant, so a last-moment concurrent abort
795
+ * wins the race and the callback is skipped.
796
+ * - When the observed signal aborts for any reason, the watchdog self-cleans its pending timer; the consumer never needs to unwire it at teardown.
797
+ * - `clear()` cancels any pending fire without aborting anything and leaves the watchdog re-armable.
798
+ * - `[Symbol.dispose]` clears the pending fire and marks the watchdog permanently dead: subsequent `arm()` calls are no-ops. This matches the scope-bound semantics
799
+ * callers expect from `using` - the resource is dead when the block exits, not merely quiescent.
123
800
  *
124
- * @returns Returns a promise that resolves after the specified time elapses.
801
+ * This is a `Disposable` (synchronous) rather than `AsyncDisposable` because cancelling a timer is synchronous; there is no background work to await.
125
802
  *
126
803
  * @example
127
- * To sleep for 3 seconds before continuing execute:
128
804
  *
129
805
  * ```ts
130
- * await sleep(3000)
806
+ * using watchdog = new Watchdog({
807
+ *
808
+ * onFire: () => this.#controller.abort(new HbpuAbortError("timeout")),
809
+ * signal: this.signal,
810
+ * timeoutMs: this.#inactivityWindowMs
811
+ * });
812
+ *
813
+ * // Each time a packet / segment / message arrives, re-arm so the fire never fires.
814
+ * this.#source.on("data", () => watchdog.arm());
815
+ * watchdog.arm();
131
816
  * ```
132
817
  *
133
818
  * @category Utilities
134
819
  */
135
- export async function sleep(sleepTimer) {
136
- return new Promise(resolve => setTimeout(resolve, sleepTimer));
820
+ export class Watchdog {
821
+ #onFire;
822
+ #signal;
823
+ #timeoutMs;
824
+ #timer;
825
+ // Set to `true` exactly once when `[Symbol.dispose]` runs. Tracks "caller is done with this watchdog" as a distinct concept from "the observed signal aborted" - the
826
+ // latter can happen without disposal (normal lifetime end), and disposal can happen without signal abort (scope-bound `using` inside a longer-lived context). Both
827
+ // paths converge on the same observable behavior: `arm()` becomes a no-op.
828
+ #disposed = false;
829
+ /**
830
+ * Construct a new watchdog. The watchdog is dormant until the first `arm()` call, so construction itself schedules no timers.
831
+ *
832
+ * A cleanup handler is registered on `init.signal` through {@link onAbort} so the watchdog auto-cleans when the lifetime signal aborts - consumers do not need to
833
+ * wire teardown manually. On a pre-aborted signal `onAbort` runs the cleanup inline; `clear()` is a no-op on a freshly-constructed watchdog (no timer has been
834
+ * armed yet), so the pre-aborted path unwinds harmlessly. A later `arm()` short-circuits on the same aborted check, so no timer is ever scheduled either way.
835
+ *
836
+ * @param init - Required init options. See {@link WatchdogInit}.
837
+ */
838
+ constructor(init) {
839
+ this.#onFire = init.onFire;
840
+ this.#signal = init.signal;
841
+ this.#timeoutMs = init.timeoutMs;
842
+ // Self-cleaning via the unified `onAbort` primitive: a pending timer after signal abort would only fire, see the aborted guard, and no-op. Clearing proactively
843
+ // keeps active-timer counts accurate across short-lived watchdogs and lets the event loop exit promptly in tests that hold no other references. This is a long-
844
+ // lived registration - the returned disposer is discarded because the listener is intended to live until the signal fires, and `onAbort`'s `{ once: true }` auto-
845
+ // removes it on fire.
846
+ onAbort(this.#signal, () => this.clear());
847
+ }
848
+ /**
849
+ * Start or restart the inactivity window. The pending timer (if any) is cancelled and a fresh one is scheduled for `timeoutMs` in the future. A no-op when the
850
+ * observed signal has already aborted or the watchdog has been disposed - in either state there is nothing live to protect, and scheduling a timer would violate the
851
+ * `using` contract callers rely on.
852
+ */
853
+ arm() {
854
+ if (this.#disposed || this.#signal.aborted) {
855
+ return;
856
+ }
857
+ if (this.#timer !== undefined) {
858
+ clearTimeout(this.#timer);
859
+ }
860
+ this.#timer = setTimeout(() => {
861
+ // Null the handle first so a re-entrant `clear()` inside `onFire` is a cheap no-op and so observers inspecting `#timer` post-fire see the correct state.
862
+ this.#timer = undefined;
863
+ // Lose a tight race against a concurrent abort cleanly: if the signal fired between timer scheduling and the callback running, the aborted state already
864
+ // expresses the outcome and `onFire` is redundant at best, double-teardown at worst. Disposal sets the same state through a different gate.
865
+ if (this.#disposed || this.#signal.aborted) {
866
+ return;
867
+ }
868
+ this.#onFire();
869
+ }, this.#timeoutMs);
870
+ }
871
+ /**
872
+ * Cancel any pending fire without aborting anything and without marking the watchdog as permanently dead. Subsequent `arm()` calls continue to work. Safe to call
873
+ * when no arm is pending - repeat calls are no-ops.
874
+ */
875
+ clear() {
876
+ if (this.#timer !== undefined) {
877
+ clearTimeout(this.#timer);
878
+ this.#timer = undefined;
879
+ }
880
+ }
881
+ /**
882
+ * `Disposable` implementation. Clears any pending fire AND permanently disables the watchdog: after this runs, `arm()` is a no-op and no further `onFire` calls can
883
+ * occur. This is the contract `using watchdog = new Watchdog(...)` relies on - the resource is dead when the block exits, not merely quiescent. Repeated
884
+ * disposal is a no-op. Because the class does not own an abort controller, disposal does not signal anything to the rest of the system.
885
+ */
886
+ [Symbol.dispose]() {
887
+ this.#disposed = true;
888
+ this.clear();
889
+ }
137
890
  }
138
891
  /**
139
892
  * Start case a string, capitalizing the first letter of each word unconditionally.