homebridge-plugin-utils 1.35.0 → 2.0.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.
- package/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +94 -45
- package/dist/backpressure.js +229 -93
- package/dist/backpressure.js.map +1 -1
- package/dist/cli/index.d.ts +95 -0
- package/dist/cli/index.js +355 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -262
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +49 -2
- package/dist/ffmpeg/fmp4.js +47 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
- package/dist/ffmpeg/mp4-assembler.js +387 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +142 -96
- package/dist/ffmpeg/process.js +406 -278
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +302 -188
- package/dist/ffmpeg/record.js +408 -567
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +144 -0
- package/dist/ffmpeg/recording-process-double.js +168 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +473 -251
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -2
- package/dist/ffmpeg/settings.js +20 -5
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -150
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +677 -0
- package/dist/ui/webUi.mjs +192 -87
- package/dist/util.d.ts +602 -45
- package/dist/util.js +783 -78
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
package/dist/util.js
CHANGED
|
@@ -2,138 +2,843 @@
|
|
|
2
2
|
*
|
|
3
3
|
* util.ts: Useful utility functions when writing TypeScript.
|
|
4
4
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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";
|
|
10
58
|
// Validates a name against HomeKit's naming conventions. Compiled once at module scope since this sits on the fast path of sanitizeName().
|
|
11
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, idempotent, 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]: () => { } };
|
|
12
72
|
/**
|
|
13
|
-
*
|
|
73
|
+
* The canonical abort error used across `homebridge-plugin-utils`.
|
|
14
74
|
*
|
|
15
|
-
* @
|
|
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.
|
|
16
82
|
*
|
|
17
|
-
* @returns Returns the value as a human-readable string.
|
|
18
83
|
* @example
|
|
19
84
|
*
|
|
20
85
|
* ```ts
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
86
|
+
* import { HbpuAbortError, isHbpuAbortReason } from "homebridge-plugin-utils";
|
|
87
|
+
*
|
|
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
|
+
* }
|
|
26
101
|
* ```
|
|
102
|
+
*
|
|
103
|
+
* @category Utilities
|
|
27
104
|
*/
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
105
|
+
export class HbpuAbortError extends Error {
|
|
106
|
+
/**
|
|
107
|
+
* The discriminator. 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;
|
|
32
119
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Type guard: returns `true` if `error` is an {@link HbpuAbortError}.
|
|
123
|
+
*
|
|
124
|
+
* Use this to discriminate 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 discriminate 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 discriminate 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;
|
|
37
171
|
}
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
return (
|
|
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");
|
|
41
175
|
}
|
|
42
176
|
/**
|
|
43
|
-
*
|
|
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.
|
|
44
183
|
*
|
|
45
|
-
*
|
|
46
|
-
* @param retryInterval - Interval to retry, in milliseconds.
|
|
47
|
-
* @param totalRetries - Optionally, specify the total number of retries.
|
|
184
|
+
* Returning a `Disposable` serves two patterns through one primitive:
|
|
48
185
|
*
|
|
49
|
-
*
|
|
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.
|
|
50
191
|
*
|
|
51
|
-
*
|
|
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).
|
|
52
201
|
*
|
|
53
202
|
* @example
|
|
203
|
+
*
|
|
54
204
|
* ```ts
|
|
55
|
-
* //
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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);
|
|
58
209
|
*
|
|
59
|
-
*
|
|
210
|
+
* onAbort(this.signal, () => this.#teardown());
|
|
60
211
|
*
|
|
61
|
-
*
|
|
62
|
-
* return Math.random() > 0.5 || attempt === 5;
|
|
63
|
-
* }, 1000, 5);
|
|
212
|
+
* if(this.signal.aborted) {
|
|
64
213
|
*
|
|
65
|
-
*
|
|
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 load-bearing 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
|
+
* }
|
|
66
237
|
* ```
|
|
67
238
|
*
|
|
68
239
|
* @category Utilities
|
|
69
240
|
*/
|
|
70
|
-
export
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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` is idempotent - 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
|
+
}
|
|
336
|
+
/**
|
|
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}.
|
|
346
|
+
*
|
|
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.
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
*
|
|
352
|
+
* ```ts
|
|
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
|
+
* });
|
|
363
|
+
* ```
|
|
364
|
+
*
|
|
365
|
+
* @category Utilities
|
|
366
|
+
*/
|
|
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" invariant 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
|
+
}
|
|
81
400
|
}
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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");
|
|
403
|
+
}
|
|
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;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
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.
|
|
539
|
+
*
|
|
540
|
+
* @param signals - Ordered list of signal sources. `undefined` entries are filtered out; order is preserved among defined entries.
|
|
541
|
+
*
|
|
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`.
|
|
544
|
+
*
|
|
545
|
+
* @throws `TypeError` if every input is `undefined` - the caller passed no concrete signal to compose.
|
|
546
|
+
*
|
|
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 invariant: 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 discrimination 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
|
+
*
|
|
604
|
+
* ```ts
|
|
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({
|
|
610
|
+
*
|
|
611
|
+
* loop: async (signal) => {
|
|
612
|
+
*
|
|
613
|
+
* for await (const event of client.observe(selector, { signal })) {
|
|
614
|
+
*
|
|
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
|
+
* });
|
|
621
|
+
* ```
|
|
622
|
+
*
|
|
623
|
+
* @category Utilities
|
|
624
|
+
*/
|
|
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 invariant this primitive centralizes: discriminate 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;
|
|
85
637
|
}
|
|
86
|
-
|
|
87
|
-
await sleep(retryInterval);
|
|
638
|
+
onError(error);
|
|
88
639
|
}
|
|
89
640
|
}
|
|
90
641
|
/**
|
|
91
|
-
*
|
|
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
|
+
/**
|
|
681
|
+
* Run an abortable operation with signal-based cancellation.
|
|
682
|
+
*
|
|
683
|
+
* 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
|
|
684
|
+
* 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
|
|
685
|
+
* 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
|
|
686
|
+
* from the factory propagate normally.
|
|
92
687
|
*
|
|
93
|
-
* @typeParam T - The type of value the promise resolves with.
|
|
94
|
-
* @param
|
|
95
|
-
* @param
|
|
688
|
+
* @typeParam T - The type of value the factory's promise resolves with.
|
|
689
|
+
* @param fn - A factory that receives the composed abort signal and returns the promise to await.
|
|
690
|
+
* @param options - Abort options. Provide `timeout` (milliseconds), an external `signal`, or both.
|
|
96
691
|
*
|
|
97
|
-
* @returns
|
|
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.
|
|
692
|
+
* @returns Resolves with the factory's result if it completes before abort, or `null` if the signal fires first.
|
|
99
693
|
*
|
|
100
694
|
* @example
|
|
101
695
|
* ```ts
|
|
102
|
-
* //
|
|
103
|
-
* const result = await
|
|
104
|
-
* console.log(result); // 42
|
|
696
|
+
* // Timeout only - cancel after 500ms.
|
|
697
|
+
* const result = await runWithAbort((signal) => fetch(url, { signal }), { timeout: 500 });
|
|
105
698
|
*
|
|
106
|
-
* //
|
|
107
|
-
* const
|
|
108
|
-
* const result2 = await
|
|
109
|
-
*
|
|
699
|
+
* // External signal only - cancel on demand.
|
|
700
|
+
* const controller = new AbortController();
|
|
701
|
+
* const result2 = await runWithAbort((signal) => once(emitter, "data", { signal }), { signal: controller.signal });
|
|
702
|
+
* controller.abort();
|
|
703
|
+
*
|
|
704
|
+
* // Both - cancel on demand or after 5 seconds, whichever comes first.
|
|
705
|
+
* const result3 = await runWithAbort((signal) => once(emitter, "data", { signal }), { signal: controller.signal, timeout: 5000 });
|
|
110
706
|
* ```
|
|
111
707
|
*
|
|
112
708
|
* @category Utilities
|
|
113
709
|
*/
|
|
114
|
-
export async function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
710
|
+
export async function runWithAbort(fn, options) {
|
|
711
|
+
// Route through `composeSignals` so this helper uses the same signal-composition primitive every other HBPU resource class uses. The discriminated union
|
|
712
|
+
// guarantees at least one of `signal` / `timeout` is defined, which means `composeSignals` always receives at least one concrete signal and never throws its
|
|
713
|
+
// empty-input guard. When only a timeout is supplied, `composeSignals` returns that single timeout signal unwrapped (no needless `AbortSignal.any` allocation).
|
|
714
|
+
const callerSignal = ("signal" in options) ? options.signal : undefined;
|
|
715
|
+
const timeoutSignal = (options.timeout !== undefined) ? AbortSignal.timeout(options.timeout) : undefined;
|
|
716
|
+
const signal = composeSignals(callerSignal, timeoutSignal);
|
|
717
|
+
// If the signal is already aborted, return immediately without starting any work.
|
|
718
|
+
if (signal.aborted) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
// 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
|
|
722
|
+
// for cancellation state...the exception type varies by API and abort reason (AbortError from manual abort, TimeoutError from AbortSignal.timeout(), or any custom
|
|
723
|
+
// reason). Genuine errors that occur before the signal fires propagate normally.
|
|
724
|
+
try {
|
|
725
|
+
return await fn(signal);
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
// The signal's aborted state changes asynchronously while the factory is running...the early-out check above doesn't constrain this.
|
|
729
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
730
|
+
if (signal.aborted) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
118
735
|
}
|
|
119
736
|
/**
|
|
120
|
-
*
|
|
737
|
+
* Re-armable inactivity watchdog.
|
|
738
|
+
*
|
|
739
|
+
* 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
|
|
740
|
+
* pacing, the RTP demuxer's inbound-packet cadence - composes a single `Watchdog` instance to implement the shared "abort if no activity within window" pattern.
|
|
741
|
+
*
|
|
742
|
+
* The semantics are minimal on purpose:
|
|
121
743
|
*
|
|
122
|
-
*
|
|
744
|
+
* - `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
|
|
745
|
+
* call is a no-op.
|
|
746
|
+
* - 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
|
|
747
|
+
* wins the race and the callback is skipped.
|
|
748
|
+
* - When the observed signal aborts for any reason, the watchdog self-cleans its pending timer; the consumer never needs to unwire it at teardown.
|
|
749
|
+
* - `clear()` cancels any pending fire without aborting anything and leaves the watchdog re-armable.
|
|
750
|
+
* - `[Symbol.dispose]` clears the pending fire and marks the watchdog permanently dead: subsequent `arm()` calls are no-ops. This matches the scope-bound semantics
|
|
751
|
+
* callers expect from `using` - the resource is dead when the block exits, not merely quiescent.
|
|
123
752
|
*
|
|
124
|
-
*
|
|
753
|
+
* This is a `Disposable` (synchronous) rather than `AsyncDisposable` because cancelling a timer is synchronous; there is no background work to await.
|
|
125
754
|
*
|
|
126
755
|
* @example
|
|
127
|
-
* To sleep for 3 seconds before continuing execute:
|
|
128
756
|
*
|
|
129
757
|
* ```ts
|
|
130
|
-
*
|
|
758
|
+
* using watchdog = new Watchdog({
|
|
759
|
+
*
|
|
760
|
+
* onFire: () => this.#controller.abort(new HbpuAbortError("timeout")),
|
|
761
|
+
* signal: this.signal,
|
|
762
|
+
* timeoutMs: this.#inactivityWindowMs
|
|
763
|
+
* });
|
|
764
|
+
*
|
|
765
|
+
* // Each time a packet / segment / message arrives, re-arm so the fire never fires.
|
|
766
|
+
* this.#source.on("data", () => watchdog.arm());
|
|
767
|
+
* watchdog.arm();
|
|
131
768
|
* ```
|
|
132
769
|
*
|
|
133
770
|
* @category Utilities
|
|
134
771
|
*/
|
|
135
|
-
export
|
|
136
|
-
|
|
772
|
+
export class Watchdog {
|
|
773
|
+
#onFire;
|
|
774
|
+
#signal;
|
|
775
|
+
#timeoutMs;
|
|
776
|
+
#timer;
|
|
777
|
+
// 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
|
|
778
|
+
// latter can happen without disposal (normal lifetime end), and disposal can happen without signal abort (scope-bound `using` inside a longer-lived context). Both
|
|
779
|
+
// paths converge on the same observable behavior: `arm()` becomes a no-op.
|
|
780
|
+
#disposed = false;
|
|
781
|
+
/**
|
|
782
|
+
* Construct a new watchdog. The watchdog is dormant until the first `arm()` call, so construction itself schedules no timers.
|
|
783
|
+
*
|
|
784
|
+
* 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
|
|
785
|
+
* 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
|
|
786
|
+
* 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.
|
|
787
|
+
*
|
|
788
|
+
* @param init - Required init options. See {@link WatchdogInit}.
|
|
789
|
+
*/
|
|
790
|
+
constructor(init) {
|
|
791
|
+
this.#onFire = init.onFire;
|
|
792
|
+
this.#signal = init.signal;
|
|
793
|
+
this.#timeoutMs = init.timeoutMs;
|
|
794
|
+
// 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
|
|
795
|
+
// 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-
|
|
796
|
+
// lived registration - the returned disposer is discarded because the listener is intended to live until the signal fires, and `onAbort`'s `{ once: true }` auto-
|
|
797
|
+
// removes it on fire.
|
|
798
|
+
onAbort(this.#signal, () => this.clear());
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 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
|
|
802
|
+
* 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
|
|
803
|
+
* `using` contract callers rely on.
|
|
804
|
+
*/
|
|
805
|
+
arm() {
|
|
806
|
+
if (this.#disposed || this.#signal.aborted) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (this.#timer !== undefined) {
|
|
810
|
+
clearTimeout(this.#timer);
|
|
811
|
+
}
|
|
812
|
+
this.#timer = setTimeout(() => {
|
|
813
|
+
// 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.
|
|
814
|
+
this.#timer = undefined;
|
|
815
|
+
// Lose a tight race against a concurrent abort cleanly: if the signal fired between timer scheduling and the callback running, the aborted state already
|
|
816
|
+
// expresses the outcome and `onFire` is redundant at best, double-teardown at worst. Disposal sets the same invariant through a different gate.
|
|
817
|
+
if (this.#disposed || this.#signal.aborted) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.#onFire();
|
|
821
|
+
}, this.#timeoutMs);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Cancel any pending fire without aborting anything and without marking the watchdog as permanently dead. Subsequent `arm()` calls continue to work. Safe to call
|
|
825
|
+
* when no arm is pending - the method is idempotent.
|
|
826
|
+
*/
|
|
827
|
+
clear() {
|
|
828
|
+
if (this.#timer !== undefined) {
|
|
829
|
+
clearTimeout(this.#timer);
|
|
830
|
+
this.#timer = undefined;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* `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
|
|
835
|
+
* occur. This is the contract `using watchdog = new Watchdog(...)` relies on - the resource is dead when the block exits, not merely quiescent. Idempotent; repeated
|
|
836
|
+
* 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.
|
|
837
|
+
*/
|
|
838
|
+
[Symbol.dispose]() {
|
|
839
|
+
this.#disposed = true;
|
|
840
|
+
this.clear();
|
|
841
|
+
}
|
|
137
842
|
}
|
|
138
843
|
/**
|
|
139
844
|
* Start case a string, capitalizing the first letter of each word unconditionally.
|