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