@cplieger/actions 1.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.
Files changed (50) hide show
  1. package/.editorconfig +19 -0
  2. package/.htmlvalidate.json +28 -0
  3. package/.stylelintrc.json +67 -0
  4. package/LICENSE +692 -0
  5. package/README.md +176 -0
  6. package/dist/src/api.d.ts +56 -0
  7. package/dist/src/api.js +158 -0
  8. package/dist/src/cleanup.d.ts +11 -0
  9. package/dist/src/cleanup.js +63 -0
  10. package/dist/src/debounce.d.ts +28 -0
  11. package/dist/src/debounce.js +91 -0
  12. package/dist/src/define-helpers.d.ts +20 -0
  13. package/dist/src/define-helpers.js +83 -0
  14. package/dist/src/define.d.ts +14 -0
  15. package/dist/src/define.js +627 -0
  16. package/dist/src/error.d.ts +42 -0
  17. package/dist/src/error.js +174 -0
  18. package/dist/src/index.d.ts +17 -0
  19. package/dist/src/index.js +22 -0
  20. package/dist/src/loading.d.ts +15 -0
  21. package/dist/src/loading.js +114 -0
  22. package/dist/src/notifier.d.ts +21 -0
  23. package/dist/src/notifier.js +21 -0
  24. package/dist/src/poll.d.ts +23 -0
  25. package/dist/src/poll.js +120 -0
  26. package/dist/src/registry.d.ts +18 -0
  27. package/dist/src/registry.js +210 -0
  28. package/dist/src/retry.d.ts +9 -0
  29. package/dist/src/retry.js +78 -0
  30. package/dist/src/transport.d.ts +46 -0
  31. package/dist/src/transport.js +70 -0
  32. package/dist/src/types.d.ts +157 -0
  33. package/dist/src/types.js +7 -0
  34. package/eslint.config.base.mjs +186 -0
  35. package/jsr.json +22 -0
  36. package/package.json +41 -0
  37. package/src/api.ts +209 -0
  38. package/src/cleanup.ts +76 -0
  39. package/src/debounce.ts +128 -0
  40. package/src/define-helpers.ts +97 -0
  41. package/src/define.ts +699 -0
  42. package/src/error.ts +184 -0
  43. package/src/index.ts +60 -0
  44. package/src/loading.ts +149 -0
  45. package/src/notifier.ts +41 -0
  46. package/src/poll.ts +150 -0
  47. package/src/registry.ts +225 -0
  48. package/src/retry.ts +80 -0
  49. package/src/transport.ts +112 -0
  50. package/src/types.ts +198 -0
@@ -0,0 +1,174 @@
1
+ // ActionError: thrown by an action's run() function to signal a typed
2
+ // failure. Carries optional HTTP status, server-side error code, and
3
+ // cause chain for diagnostics.
4
+ //
5
+ // Public surface:
6
+ // - ActionError : structured error class
7
+ // - hasErrorString : narrows parsed JSON bodies with `{ error: "..." }`
8
+ // - classifyFetchError: normalize fetch catch-block errors
9
+ // - retryNetwork : preset classifier — network/timeout/transient HTTP
10
+ //
11
+ // Internal:
12
+ // - toActionError: coerce thrown values into ActionErrorLike (used by define.ts)
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * Structured error thrown from an action's `run()` to signal a typed failure.
16
+ * Carries optional HTTP status and server-side error code for downstream
17
+ * classification (retry eligibility, notification formatting, telemetry).
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * throw new ActionError("Server rejected", { status: 409, code: "conflict" });
22
+ * ```
23
+ */
24
+ export class ActionError extends Error {
25
+ status;
26
+ code;
27
+ cause;
28
+ constructor(message, opts) {
29
+ super(message);
30
+ this.name = "ActionError";
31
+ if (opts?.status !== undefined) {
32
+ this.status = opts.status;
33
+ }
34
+ if (opts?.code !== undefined) {
35
+ this.code = opts.code;
36
+ }
37
+ if (opts?.cause !== undefined) {
38
+ this.cause = opts.cause;
39
+ }
40
+ }
41
+ }
42
+ /** Type predicate: true when `v` is a non-null object with a string `error` property. */
43
+ export function hasErrorString(v) {
44
+ if (typeof v !== "object" || v === null || !("error" in v)) {
45
+ return false;
46
+ }
47
+ return typeof v.error === "string";
48
+ }
49
+ /** Coerce any thrown value into an ActionErrorLike snapshot. Internal. */
50
+ export function toActionError(e) {
51
+ if (e instanceof ActionError) {
52
+ const r = {
53
+ message: e.message,
54
+ };
55
+ if (e.status !== undefined) {
56
+ r.status = e.status;
57
+ }
58
+ if (e.code !== undefined) {
59
+ r.code = e.code;
60
+ }
61
+ if (e.cause !== undefined) {
62
+ r.cause = e.cause;
63
+ }
64
+ return r;
65
+ }
66
+ if (e instanceof DOMException) {
67
+ const code = e.name === "TimeoutError"
68
+ ? "timeout"
69
+ : e.name === "AbortError"
70
+ ? "cancelled"
71
+ : e.name === "NetworkError"
72
+ ? "network"
73
+ : e.name.toLowerCase();
74
+ const isNetLayer = code === "network" || code === "timeout";
75
+ return { message: e.message, code, ...(isNetLayer && { status: 0 }), cause: e };
76
+ }
77
+ if (e instanceof AggregateError) {
78
+ const first = e.errors[0];
79
+ const inner = first instanceof Error ? first.message : e.message;
80
+ return { message: inner || e.message, code: "aggregate", cause: e };
81
+ }
82
+ if (e instanceof Error) {
83
+ const rawStatus = "status" in e ? e.status : undefined;
84
+ const status = typeof rawStatus === "number" ? rawStatus : undefined;
85
+ const rawCode = "code" in e ? e.code : undefined;
86
+ const code = typeof rawCode === "string" ? rawCode : undefined;
87
+ const r = {
88
+ message: e.message,
89
+ cause: e,
90
+ };
91
+ if (status !== undefined) {
92
+ r.status = status;
93
+ }
94
+ if (code !== undefined) {
95
+ r.code = code;
96
+ }
97
+ return r;
98
+ }
99
+ if (typeof e === "object" && e !== null && "message" in e) {
100
+ const obj = e;
101
+ const message = typeof obj.message === "string" ? obj.message : String(obj.message);
102
+ const status = typeof obj.status === "number" ? obj.status : undefined;
103
+ const code = typeof obj.code === "string" ? obj.code : undefined;
104
+ const r = {
105
+ message,
106
+ cause: e,
107
+ };
108
+ if (status !== undefined) {
109
+ r.status = status;
110
+ }
111
+ if (code !== undefined) {
112
+ r.code = code;
113
+ }
114
+ return r;
115
+ }
116
+ if (e === null) {
117
+ return { message: "Unknown error (null thrown)", code: "unknown" };
118
+ }
119
+ if (e === undefined) {
120
+ return { message: "Unknown error (undefined thrown)", code: "unknown" };
121
+ }
122
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- intentional coercion of unknown thrown value
123
+ const msg = String(e);
124
+ return {
125
+ message: msg !== "" ? msg : "Unknown error (empty value thrown)",
126
+ code: "unknown",
127
+ cause: e,
128
+ };
129
+ }
130
+ /**
131
+ * Classify a caught fetch error into an ActionError with a canonical code.
132
+ *
133
+ * Classification priority:
134
+ * 1. Signal already aborted → "cancelled"
135
+ * 2. DOMException TimeoutError / AbortError with live signal → "timeout"
136
+ * 3. TypeError → "network" (browsers throw TypeError for network failures)
137
+ * 4. Everything else → "network"
138
+ */
139
+ export function classifyFetchError(e, signal) {
140
+ if (signal.aborted) {
141
+ return new ActionError("Request cancelled", { code: "cancelled", cause: e });
142
+ }
143
+ if (e instanceof DOMException) {
144
+ if (e.name === "TimeoutError" || e.name === "AbortError") {
145
+ return new ActionError("Request timed out", { status: 0, code: "timeout", cause: e });
146
+ }
147
+ }
148
+ if (e instanceof TypeError) {
149
+ return new ActionError(e.message, { status: 0, code: "network", cause: e });
150
+ }
151
+ const msg = e instanceof Error ? e.message : "network error";
152
+ return new ActionError(msg, { status: 0, code: "network", cause: e });
153
+ }
154
+ /** HTTP statuses that represent transient server-side conditions. */
155
+ const TRANSIENT_STATUSES = new Set([408, 429, 502, 503, 504]);
156
+ /**
157
+ * Retry classifier preset: matches network/timeout failures and transient
158
+ * HTTP statuses (408, 429, 502, 503, 504). Always excludes cancellation.
159
+ */
160
+ export function retryNetwork(err) {
161
+ if (err.code === "cancelled") {
162
+ return false;
163
+ }
164
+ if (err.code === "network" || err.code === "timeout") {
165
+ return true;
166
+ }
167
+ if (err.status === 0) {
168
+ return true;
169
+ }
170
+ if (err.status !== undefined && TRANSIENT_STATUSES.has(err.status)) {
171
+ return true;
172
+ }
173
+ return false;
174
+ }
@@ -0,0 +1,17 @@
1
+ export { configure } from "./notifier.js";
2
+ export type { Notifier, NotifierRetry } from "./notifier.js";
3
+ export { configureTransport, transportAction } from "./transport.js";
4
+ export type { TransportSendResult, TransportCommand, TransportSendFn } from "./transport.js";
5
+ export { defineAction } from "./define.js";
6
+ export { apiAction, configureApi, API_TIMEOUT_MS, withTimeout } from "./api.js";
7
+ export type { ApiConfig, ApiActionDefinition } from "./api.js";
8
+ export { ActionError, hasErrorString, classifyFetchError, retryNetwork } from "./error.js";
9
+ export { subscribe as subscribeToActions, subscribeByName, getActionLog, pendingCount, isPending, } from "./registry.js";
10
+ export { bindLoadingState } from "./loading.js";
11
+ export { registerCleanup } from "./cleanup.js";
12
+ export { debouncedDispatch } from "./debounce.js";
13
+ export type { DebouncedDispatch } from "./debounce.js";
14
+ export { pollAction } from "./poll.js";
15
+ export type { PollOptions } from "./poll.js";
16
+ export type { Action, ActionContext, ActionDefinition, ActionErrorLike, ActionInstance, ActionLifecycleStatus, DispatchHandle, DispatchOptions, NotificationSpec, RegistryListener, RequestSpec, RetryConfig, ToastSpec, } from "./types.js";
17
+ export { RETRY_STANDARD } from "./types.js";
@@ -0,0 +1,22 @@
1
+ // Public surface of the @cplieger/actions framework.
2
+ // ---------------------------------------------------------------------------
3
+ // Configuration
4
+ export { configure } from "./notifier.js";
5
+ // Transport injection
6
+ export { configureTransport, transportAction } from "./transport.js";
7
+ // Action factories
8
+ export { defineAction } from "./define.js";
9
+ export { apiAction, configureApi, API_TIMEOUT_MS, withTimeout } from "./api.js";
10
+ // Error class + utilities
11
+ export { ActionError, hasErrorString, classifyFetchError, retryNetwork } from "./error.js";
12
+ // Registry
13
+ export { subscribe as subscribeToActions, subscribeByName, getActionLog, pendingCount, isPending, } from "./registry.js";
14
+ // Loading-state helper
15
+ export { bindLoadingState } from "./loading.js";
16
+ // Cleanup hooks
17
+ export { registerCleanup } from "./cleanup.js";
18
+ // Debounce helper
19
+ export { debouncedDispatch } from "./debounce.js";
20
+ // Polling helper
21
+ export { pollAction } from "./poll.js";
22
+ export { RETRY_STANDARD } from "./types.js";
@@ -0,0 +1,15 @@
1
+ /** Element types that have a `.disabled` writable boolean. */
2
+ type DisableableElement = HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
3
+ interface BindLoadingOptions {
4
+ ariaBusy?: boolean;
5
+ preserveAriaBusy?: boolean;
6
+ pendingClass?: string;
7
+ preserveDisabled?: boolean;
8
+ disabledFn?: () => boolean;
9
+ }
10
+ /**
11
+ * Bind a button/input element's disabled / aria-busy state to one or
12
+ * more named actions.
13
+ */
14
+ export declare function bindLoadingState(actionName: string | readonly string[], el: DisableableElement, opts?: BindLoadingOptions): () => void;
15
+ export {};
@@ -0,0 +1,114 @@
1
+ // bindLoadingState: bind a button or input element's disabled / aria-busy
2
+ // state to one or more named actions' pending count.
3
+ //
4
+ // While ANY of the named actions is pending, the element gets:
5
+ // - disabled = true
6
+ // - aria-busy = "true" (omit by passing { ariaBusy: false })
7
+ // - optionally an extra CSS class via { pendingClass: "btn-loading" }
8
+ //
9
+ // Returns an unsubscribe function. Call it from the view's teardown
10
+ // hook to stop receiving updates and avoid leaking listeners.
11
+ // ---------------------------------------------------------------------------
12
+ import { subscribeByName, isPending, pendingCount } from "./registry.js";
13
+ /**
14
+ * Bind a button/input element's disabled / aria-busy state to one or
15
+ * more named actions.
16
+ */
17
+ export function bindLoadingState(actionName, el, opts = {}) {
18
+ const names = typeof actionName === "string" ? [actionName] : actionName;
19
+ if (names.length === 0) {
20
+ return () => {
21
+ /* noop */
22
+ };
23
+ }
24
+ const { ariaBusy = true, preserveAriaBusy = false, pendingClass, preserveDisabled = false, disabledFn, } = opts;
25
+ const manageAriaBusy = ariaBusy && !preserveAriaBusy;
26
+ let wasPending = false;
27
+ let baseDisabled = el.disabled;
28
+ let hadFocus = false;
29
+ let disposed = false;
30
+ let wasConnected = el.isConnected;
31
+ const resolveBase = () => {
32
+ if (disabledFn !== undefined) {
33
+ try {
34
+ return disabledFn();
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ return preserveDisabled ? baseDisabled : false;
41
+ };
42
+ const setIdle = () => {
43
+ el.disabled = resolveBase();
44
+ if (manageAriaBusy) {
45
+ el.removeAttribute("aria-busy");
46
+ }
47
+ if (pendingClass) {
48
+ el.classList.remove(pendingClass);
49
+ }
50
+ if (hadFocus && el.isConnected && !el.disabled) {
51
+ const active = document.activeElement;
52
+ if (active === null || active === document.body) {
53
+ el.focus();
54
+ }
55
+ }
56
+ hadFocus = false;
57
+ };
58
+ const readPending = () =>
59
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
60
+ names.length === 1 ? isPending(names[0]) : pendingCount(names) > 0;
61
+ // eslint-disable-next-line prefer-const -- assigned after apply() closure is defined
62
+ let _unsubs;
63
+ const apply = () => {
64
+ if (disposed) {
65
+ return;
66
+ }
67
+ if (wasConnected && !el.isConnected) {
68
+ disposed = true;
69
+ if (_unsubs) {
70
+ for (const u of _unsubs) {
71
+ u();
72
+ }
73
+ }
74
+ return;
75
+ }
76
+ if (el.isConnected) {
77
+ wasConnected = true;
78
+ }
79
+ const pending = readPending();
80
+ if (pending && !wasPending) {
81
+ baseDisabled = el.disabled;
82
+ hadFocus = document.activeElement === el;
83
+ }
84
+ if (pending) {
85
+ el.disabled = true;
86
+ if (manageAriaBusy) {
87
+ el.setAttribute("aria-busy", "true");
88
+ }
89
+ if (pendingClass) {
90
+ el.classList.add(pendingClass);
91
+ }
92
+ }
93
+ else if (wasPending) {
94
+ setIdle();
95
+ }
96
+ wasPending = pending;
97
+ };
98
+ const restore = () => {
99
+ if (wasPending) {
100
+ setIdle();
101
+ wasPending = false;
102
+ }
103
+ };
104
+ apply();
105
+ const unsubs = names.map((name) => subscribeByName(name, apply));
106
+ _unsubs = unsubs;
107
+ return () => {
108
+ disposed = true;
109
+ restore();
110
+ for (const u of unsubs) {
111
+ u();
112
+ }
113
+ };
114
+ }
@@ -0,0 +1,21 @@
1
+ /** Retry button descriptor passed to error notifications. */
2
+ export interface NotifierRetry {
3
+ readonly onClick: () => void;
4
+ }
5
+ /** Consumer-provided notification adapter. Implement this interface
6
+ * and pass it to `configure()` to wire up toast/notification display.
7
+ *
8
+ * All methods are optional — when not provided, the framework silently
9
+ * drops the notification (useful for headless/test environments). */
10
+ export interface Notifier {
11
+ success?(message: string): void;
12
+ error?(message: string, retry?: NotifierRetry): void;
13
+ }
14
+ /** Configure the global notifier adapter. Call once at app boot. */
15
+ export declare function configure(notifier: Notifier): void;
16
+ /** @internal Emit a success notification. */
17
+ export declare function notifySuccess(message: string): void;
18
+ /** @internal Emit an error notification. */
19
+ export declare function notifyError(message: string, retry?: NotifierRetry): void;
20
+ /** @internal Test-only: reset the notifier to the default no-op. */
21
+ export declare function _resetNotifierForTest(): void;
@@ -0,0 +1,21 @@
1
+ // Notifier interface: consumer-injected adapter for toast/notification
2
+ // display. Replaces the app-specific toast.ts import. The framework
3
+ // calls these during the action lifecycle (success/error toasts).
4
+ // ---------------------------------------------------------------------------
5
+ let _notifier = {};
6
+ /** Configure the global notifier adapter. Call once at app boot. */
7
+ export function configure(notifier) {
8
+ _notifier = notifier;
9
+ }
10
+ /** @internal Emit a success notification. */
11
+ export function notifySuccess(message) {
12
+ _notifier.success?.(message);
13
+ }
14
+ /** @internal Emit an error notification. */
15
+ export function notifyError(message, retry) {
16
+ _notifier.error?.(message, retry);
17
+ }
18
+ /** @internal Test-only: reset the notifier to the default no-op. */
19
+ export function _resetNotifierForTest() {
20
+ _notifier = {};
21
+ }
@@ -0,0 +1,23 @@
1
+ import type { Action } from "./types.js";
2
+ /** Configuration for {@link pollAction}. Controls interval timing,
3
+ * visibility-pause behavior, focus-refresh, and error backoff. */
4
+ export interface PollOptions<TResult = unknown> {
5
+ /** Quiet window between polls in ms. */
6
+ readonly interval: number;
7
+ /** When true (default), polls pause while document.hidden === true. */
8
+ readonly pauseWhenHidden?: boolean;
9
+ /** When true (default), fire an immediate poll on window focus. */
10
+ readonly refreshOnFocus?: boolean;
11
+ /** Exponential backoff on consecutive failures. */
12
+ readonly backoffOnError?: {
13
+ readonly factor: number;
14
+ readonly max: number;
15
+ };
16
+ /** Per-poll success callback. */
17
+ readonly onSuccess?: (result: TResult) => void;
18
+ }
19
+ /**
20
+ * Repeatedly dispatch `action(args)` at the given interval.
21
+ * Returns a `stop()` function.
22
+ */
23
+ export declare function pollAction<TArgs, TResult>(action: Action<TArgs, TResult>, args: TArgs, opts: PollOptions<TResult>): () => void;
@@ -0,0 +1,120 @@
1
+ // pollAction: repeat-an-action-on-interval primitive that integrates with
2
+ // the framework lifecycle. Adds pause-when-hidden, refresh-on-focus,
3
+ // exponential backoff on consecutive failures, and auto cleanup.
4
+ // ---------------------------------------------------------------------------
5
+ import { registerCleanup } from "./cleanup.js";
6
+ /**
7
+ * Repeatedly dispatch `action(args)` at the given interval.
8
+ * Returns a `stop()` function.
9
+ */
10
+ export function pollAction(action, args, opts) {
11
+ const pauseWhenHidden = opts.pauseWhenHidden !== false;
12
+ const refreshOnFocus = opts.refreshOnFocus !== false;
13
+ const baseInterval = opts.interval;
14
+ const backoff = opts.backoffOnError;
15
+ const onSuccess = opts.onSuccess;
16
+ let timer;
17
+ let stopped = false;
18
+ let paused = false;
19
+ let inFlight = false;
20
+ let failures = 0;
21
+ function nextDelay() {
22
+ if (backoff === undefined || failures === 0) {
23
+ return baseInterval;
24
+ }
25
+ return Math.min(baseInterval * Math.pow(backoff.factor, failures), backoff.max);
26
+ }
27
+ function schedule() {
28
+ if (stopped || paused) {
29
+ return;
30
+ }
31
+ if (timer !== undefined) {
32
+ clearTimeout(timer);
33
+ }
34
+ timer = setTimeout(() => {
35
+ void tick();
36
+ }, nextDelay());
37
+ }
38
+ async function tick() {
39
+ if (stopped || paused) {
40
+ return;
41
+ }
42
+ if (inFlight) {
43
+ return;
44
+ }
45
+ inFlight = true;
46
+ try {
47
+ const result = await action.dispatch(args);
48
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- state changes during async
49
+ if (stopped) {
50
+ return;
51
+ }
52
+ if (result === null) {
53
+ failures += 1;
54
+ }
55
+ else {
56
+ failures = 0;
57
+ if (onSuccess !== undefined) {
58
+ try {
59
+ onSuccess(result);
60
+ }
61
+ catch (e) {
62
+ console.error("[pollAction] onSuccess threw", e);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ finally {
68
+ inFlight = false;
69
+ }
70
+ schedule();
71
+ }
72
+ const onVisibility = () => {
73
+ if (typeof document === "undefined") {
74
+ return;
75
+ }
76
+ if (document.hidden) {
77
+ paused = true;
78
+ if (timer !== undefined) {
79
+ clearTimeout(timer);
80
+ timer = undefined;
81
+ }
82
+ }
83
+ else if (paused) {
84
+ paused = false;
85
+ void tick();
86
+ }
87
+ };
88
+ const onFocus = () => {
89
+ if (stopped || paused || inFlight) {
90
+ return;
91
+ }
92
+ void tick();
93
+ };
94
+ function stop() {
95
+ if (stopped) {
96
+ return;
97
+ }
98
+ stopped = true;
99
+ if (timer !== undefined) {
100
+ clearTimeout(timer);
101
+ timer = undefined;
102
+ }
103
+ listenerCtrl.abort();
104
+ }
105
+ const listenerCtrl = new AbortController();
106
+ if (pauseWhenHidden && typeof document !== "undefined") {
107
+ document.addEventListener("visibilitychange", onVisibility, { signal: listenerCtrl.signal });
108
+ if (document.hidden) {
109
+ paused = true;
110
+ }
111
+ }
112
+ if (refreshOnFocus && typeof window !== "undefined") {
113
+ window.addEventListener("focus", onFocus, { signal: listenerCtrl.signal });
114
+ }
115
+ registerCleanup(stop);
116
+ if (!paused) {
117
+ void tick();
118
+ }
119
+ return stop;
120
+ }
@@ -0,0 +1,18 @@
1
+ import type { ActionInstance, RegistryListener } from "./types.js";
2
+ /** Record a state transition. Called by define.ts. */
3
+ export declare function record(instance: ActionInstance): void;
4
+ /** Subscribe to all action lifecycle events. */
5
+ export declare function subscribe(fn: RegistryListener): () => void;
6
+ /** Subscribe to lifecycle events for a single action name. */
7
+ export declare function subscribeByName(name: string, fn: RegistryListener): () => void;
8
+ /** @internal Test-only public surface. */
9
+ export declare function recentLog(): readonly ActionInstance[];
10
+ /** Read the recent action log. Useful for devtools integration and
11
+ * debugging panels. Returns a snapshot of all live entries. */
12
+ export declare const getActionLog: typeof recentLog;
13
+ /** O(1) check: true if at least one instance of the named action is pending. */
14
+ export declare function isPending(name: string): boolean;
15
+ /** Pending count for action(s). */
16
+ export declare function pendingCount(names?: readonly string[]): number;
17
+ /** Test-only: clear log + listeners. */
18
+ export declare function _resetForTest(): void;