@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
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # actions
2
+
3
+ [![CI](https://github.com/cplieger/actions/actions/workflows/ci.yaml/badge.svg)](https://github.com/cplieger/actions/actions/workflows/ci.yaml)
4
+ [![npm](https://img.shields.io/npm/v/@cplieger/actions)](https://www.npmjs.com/package/@cplieger/actions)
5
+ [![JSR](https://jsr.io/badges/@cplieger/actions)](https://jsr.io/@cplieger/actions)
6
+ [![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](./LICENSE)
7
+
8
+ > Declarative UI-actions framework with lifecycle management, retry, debounce, and polling.
9
+
10
+ A standalone TypeScript library for defining and dispatching UI actions with full lifecycle support: optimistic updates, automatic retry with backoff, scope serialization, dedupe collapsing, notification wiring, and a registry for observability. Zero runtime dependencies — notification display and streaming transport are injected by the consumer via small interfaces.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npx jsr add @cplieger/actions
16
+ # or
17
+ npm i @cplieger/actions
18
+ ```
19
+
20
+ Requires TypeScript ≥ 5.0 and a bundler that supports ESM.
21
+
22
+ ## Usage
23
+
24
+ ```typescript
25
+ import { configure, defineAction, apiAction, retryNetwork } from "@cplieger/actions";
26
+
27
+ // Wire up your notification adapter at boot
28
+ configure({
29
+ success: (msg) => showToast("success", msg),
30
+ error: (msg, retry) => showToast("error", msg, retry?.onClick),
31
+ });
32
+
33
+ // Define an action backed by HTTP
34
+ const deleteItem = apiAction<string>({
35
+ name: "items.delete",
36
+ request: (id) => ({ method: "DELETE", path: `/api/items/${id}` }),
37
+ error: "Couldn't delete item",
38
+ retryable: retryNetwork,
39
+ retry: { count: 2, delay: 300 },
40
+ });
41
+
42
+ // Dispatch it
43
+ await deleteItem.dispatch(itemId);
44
+ ```
45
+
46
+ ## Injection Points
47
+
48
+ The framework provides three adapter injection points:
49
+
50
+ - **Notifier** (`configure()`): Provides `success(msg)` and `error(msg, retry?)` methods for displaying notifications. Without configuration, notifications are silently dropped.
51
+
52
+ - **API** (`configureApi()`): Configures the HTTP layer used by all `apiAction` instances — base URL, auth/CSRF headers, credentials mode, or a custom fetch implementation. Without configuration, `apiAction` uses the global `fetch` with relative paths.
53
+
54
+ - **Transport** (`configureTransport()`): Provides a `send(cmd, opts)` function for SSE/streaming actions. Only needed if using `transportAction`.
55
+
56
+ ### HTTP Customization (configureApi)
57
+
58
+ ```typescript
59
+ import { configureApi } from "@cplieger/actions";
60
+
61
+ configureApi({
62
+ baseUrl: "https://api.example.com/v1",
63
+ credentials: "include",
64
+ prepareHeaders: (headers, { spec }) => {
65
+ headers.set("Authorization", `Bearer ${getToken()}`);
66
+ headers.set("X-CSRF-Token", getCsrfToken());
67
+ },
68
+ });
69
+ ```
70
+
71
+ Options (mirrors RTK `fetchBaseQuery`):
72
+
73
+ - `baseUrl` — prepended to every `RequestSpec.path`
74
+ - `prepareHeaders(headers, { spec })` — inject headers per-request (may be async)
75
+ - `credentials` — `RequestInit.credentials` mode (e.g. `"include"` for cookies)
76
+ - `fetchFn` — custom fetch implementation (SSR, testing)
77
+
78
+ Per-request headers can also be set directly on `RequestSpec`:
79
+
80
+ ```typescript
81
+ const action = apiAction({
82
+ name: "items.create",
83
+ request: (item) => ({
84
+ method: "POST",
85
+ path: "/items",
86
+ body: item,
87
+ headers: { "X-Request-Id": crypto.randomUUID() },
88
+ }),
89
+ });
90
+ ```
91
+
92
+ ## API
93
+
94
+ - `configure(notifier)` — inject the notification adapter
95
+ - `configureApi(opts)` — configure the HTTP layer (baseUrl, headers, credentials, fetchFn)
96
+ - `configureTransport(fn)` — inject the streaming transport adapter
97
+ - `defineAction(def)` — create an action from a declarative definition
98
+ - `apiAction(def)` — create an HTTP-backed action (uses `fetch`)
99
+ - `transportAction(def)` — create a transport/SSE-backed action
100
+ - `debouncedDispatch(action, opts)` — debounce wrapper
101
+ - `pollAction(action, args, opts)` — interval polling with pause/backoff
102
+ - `bindLoadingState(name, el, opts?)` — bind element disabled state to action pending
103
+ - `subscribeToActions(fn)` — subscribe to all lifecycle events
104
+ - `subscribeByName(name, fn)` — subscribe to lifecycle events for a single action name
105
+ - `getActionLog()` — read the recent action log (for devtools/debugging)
106
+ - `pendingCount(names?)` — query pending action count
107
+ - `isPending(name)` — O(1) check if a named action is in-flight
108
+ - `registerCleanup(fn)` — register teardown hooks for page unload
109
+ - `ActionError` — structured error class with status/code
110
+ - `retryNetwork` — preset retry classifier for transient failures
111
+ - `classifyFetchError(err)` — classify fetch errors (network vs timeout vs HTTP)
112
+ - `hasErrorString(err)` — type guard for objects with a `.message` string
113
+ - `withTimeout(signal, ms)` — compose an AbortSignal with a timeout
114
+ - `API_TIMEOUT_MS` — default API request timeout (30 000 ms)
115
+ - `RETRY_STANDARD` — standard retry config (2 retries, 300ms)
116
+
117
+ ### Definition-level callbacks (TanStack Query pattern)
118
+
119
+ `ActionDefinition` supports `onSuccess`, `onError`, and `onSettled` callbacks that fire on every dispatch without the caller needing to pass them each time:
120
+
121
+ ```typescript
122
+ const save = defineAction({
123
+ name: "doc.save",
124
+ run: async (id: string) => api.save(id),
125
+ onSuccess: (result, id) => invalidateCache(id),
126
+ onError: (err, id) => trackError("save", id, err),
127
+ onSettled: (id) => console.log("save settled for", id),
128
+ });
129
+ ```
130
+
131
+ ### Per-dispatch abort handle (RTK pattern)
132
+
133
+ `dispatch()` returns a `DispatchHandle` — a Promise augmented with an `abort()` method for per-dispatch cancellation:
134
+
135
+ ```typescript
136
+ const handle = action.dispatch(args);
137
+ // Cancel just this dispatch (others unaffected):
138
+ handle.abort();
139
+ // Still awaitable:
140
+ const result = await handle;
141
+ ```
142
+
143
+ ### Timeout option
144
+
145
+ `ActionDefinition` accepts a `timeout` (ms) that aborts `run()` via `AbortSignal.timeout()`:
146
+
147
+ ```typescript
148
+ const slow = defineAction({
149
+ name: "slow.op",
150
+ timeout: 5000, // abort after 5s
151
+ run: async (args, signal) => fetch(url, { signal }),
152
+ });
153
+ ```
154
+
155
+ ## Unsupported by Design (SKIP list)
156
+
157
+ The following features are intentionally not implemented:
158
+
159
+ | Feature | Reason |
160
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
161
+ | Query caching / stale-while-revalidate | Out of paradigm — this is an action runner, not a data cache. Use TanStack Query alongside. |
162
+ | Cache invalidation / revalidation | Data-cache concern, out of scope. |
163
+ | Framework adapters (React/Vue/Svelte) | Vanilla TS by design. Framework bindings belong in separate packages. |
164
+ | Visual DevTools panel | Separate package concern. The registry API (`getActionLog`, `subscribeByName`) provides the data. |
165
+ | SSR / hydration | Actions are imperative mutations — nothing to serialize across server→client. |
166
+ | Debounce `maxWait` | Deliberate simplification. Use `flush()` for guaranteed-fire semantics. |
167
+ | Throttle helper | Not action-specific. Consumers can throttle before calling `dispatch()`. |
168
+ | `condition` / pre-execution guard | Trivially implemented by callers with `if`. `dedupe` covers the primary use case. |
169
+ | Typed discriminated-union result | The callback model (onSuccess/onError/onSettled) is the chosen API shape. |
170
+ | `onProgress` callback | Transport-specific. Consumers wire progress in their `run()` implementation. |
171
+ | Batch dispatch | Store-level concern. This library doesn't own a store. |
172
+ | `dispose()` / action deregistration | Actions are lightweight when idle. Not a leak concern for realistic app sizes. |
173
+
174
+ ## License
175
+
176
+ GPL-3.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ import type { Action, ActionDefinition, RequestSpec } from "./types.js";
2
+ /** Default request timeout in milliseconds. */
3
+ export declare const API_TIMEOUT_MS = 30000;
4
+ /**
5
+ * Compose an optional caller signal with a fresh timeout signal.
6
+ * If the caller provides an existing signal, the result aborts when
7
+ * either the caller signal or the timeout fires — whichever comes first.
8
+ *
9
+ * @param signal - Existing signal to compose with (may be undefined).
10
+ * @param ms - Timeout in milliseconds.
11
+ * @returns A composed AbortSignal.
12
+ */
13
+ export declare function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal;
14
+ /** Configuration for the global API fetch layer. Set via `configureApi()`. */
15
+ export interface ApiConfig {
16
+ /** Base URL prepended to every RequestSpec.path (e.g. "https://api.example.com/v1"). */
17
+ readonly baseUrl?: string;
18
+ /** Inject headers on every request. Receives current headers + the request spec.
19
+ * Mutate and/or return the headers object. May be async (e.g. to read a token store). */
20
+ readonly prepareHeaders?: (headers: Headers, context: {
21
+ spec: RequestSpec;
22
+ }) => Headers | undefined | Promise<Headers | undefined>;
23
+ /** RequestInit.credentials mode applied to every request (e.g. "include" for cookies). */
24
+ readonly credentials?: RequestCredentials;
25
+ /** Custom fetch implementation. Useful for SSR (isomorphic-fetch) or testing. */
26
+ readonly fetchFn?: typeof fetch;
27
+ }
28
+ /**
29
+ * Configure the global HTTP layer used by all `apiAction` instances.
30
+ * Call once at app boot. Subsequent calls replace the previous config.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * configureApi({
35
+ * baseUrl: "https://api.example.com",
36
+ * credentials: "include",
37
+ * prepareHeaders: (headers) => {
38
+ * headers.set("Authorization", `Bearer ${getToken()}`);
39
+ * },
40
+ * });
41
+ * ```
42
+ */
43
+ export declare function configureApi(config: ApiConfig): void;
44
+ /** Reset API config. @internal Test-only. */
45
+ export declare function _resetApiConfigForTest(): void;
46
+ /** Caller-facing shape of an apiAction definition. Replaces `run` with
47
+ * a `request` function that returns an HTTP {@link RequestSpec}. */
48
+ export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit<ActionDefinition<TArgs, TResult, TOp>, "run"> {
49
+ request: (args: TArgs) => RequestSpec;
50
+ }
51
+ /**
52
+ * Build an Action from an HTTP request descriptor.
53
+ * Wraps `defineAction` with a generated `run()` that calls `fetch`
54
+ * via the global {@link ApiConfig} layer configured with {@link configureApi}.
55
+ */
56
+ export declare function apiAction<TArgs, TResult = unknown, TOp = unknown>(def: ApiActionDefinition<TArgs, TResult, TOp>): Action<TArgs, TResult>;
@@ -0,0 +1,158 @@
1
+ // apiAction: factory for HTTP-backed actions. Wraps fetch so the run()
2
+ // implementation is just the request descriptor.
3
+ // ---------------------------------------------------------------------------
4
+ import { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
5
+ import { ActionError, classifyFetchError, hasErrorString } from "./error.js";
6
+ /** Default request timeout in milliseconds. */
7
+ export const API_TIMEOUT_MS = 30_000;
8
+ /**
9
+ * Compose an optional caller signal with a fresh timeout signal.
10
+ * If the caller provides an existing signal, the result aborts when
11
+ * either the caller signal or the timeout fires — whichever comes first.
12
+ *
13
+ * @param signal - Existing signal to compose with (may be undefined).
14
+ * @param ms - Timeout in milliseconds.
15
+ * @returns A composed AbortSignal.
16
+ */
17
+ export function withTimeout(signal, ms) {
18
+ return signal !== undefined
19
+ ? AbortSignal.any([signal, AbortSignal.timeout(ms)])
20
+ : AbortSignal.timeout(ms);
21
+ }
22
+ const JSON_CT = "application/json";
23
+ let _apiConfig = {};
24
+ /**
25
+ * Configure the global HTTP layer used by all `apiAction` instances.
26
+ * Call once at app boot. Subsequent calls replace the previous config.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * configureApi({
31
+ * baseUrl: "https://api.example.com",
32
+ * credentials: "include",
33
+ * prepareHeaders: (headers) => {
34
+ * headers.set("Authorization", `Bearer ${getToken()}`);
35
+ * },
36
+ * });
37
+ * ```
38
+ */
39
+ export function configureApi(config) {
40
+ _apiConfig = config;
41
+ }
42
+ /** Reset API config. @internal Test-only. */
43
+ export function _resetApiConfigForTest() {
44
+ _apiConfig = {};
45
+ }
46
+ /**
47
+ * Build an Action from an HTTP request descriptor.
48
+ * Wraps `defineAction` with a generated `run()` that calls `fetch`
49
+ * via the global {@link ApiConfig} layer configured with {@link configureApi}.
50
+ */
51
+ export function apiAction(def) {
52
+ const { request, ...rest } = def;
53
+ return defineAction({
54
+ ...rest,
55
+ run: async (args, signal, ctx) => {
56
+ const spec = request(args);
57
+ return executeRequest(spec, signal, ctx);
58
+ },
59
+ });
60
+ }
61
+ async function executeRequest(spec, signal, ctx) {
62
+ const cfg = _apiConfig;
63
+ const init = { method: spec.method };
64
+ // Build headers via Headers API for prepareHeaders compatibility
65
+ const headers = new Headers();
66
+ if (spec.method !== "GET" && spec.body !== undefined) {
67
+ headers.set("Content-Type", JSON_CT);
68
+ init.body = JSON.stringify(spec.body);
69
+ }
70
+ if (ctx?.idempotencyKey !== undefined) {
71
+ headers.set(IDEMPOTENCY_HEADER, ctx.idempotencyKey);
72
+ }
73
+ // Per-request headers from RequestSpec
74
+ if (spec.headers !== undefined) {
75
+ for (const [k, v] of Object.entries(spec.headers)) {
76
+ headers.set(k, v);
77
+ }
78
+ }
79
+ // Global prepareHeaders hook
80
+ if (cfg.prepareHeaders !== undefined) {
81
+ await cfg.prepareHeaders(headers, { spec });
82
+ }
83
+ // Convert Headers to plain object for RequestInit
84
+ const headerObj = {};
85
+ headers.forEach((v, k) => {
86
+ headerObj[k.toLowerCase()] = v;
87
+ });
88
+ if (Object.keys(headerObj).length > 0) {
89
+ init.headers = headerObj;
90
+ }
91
+ // Credentials
92
+ if (cfg.credentials !== undefined) {
93
+ init.credentials = cfg.credentials;
94
+ }
95
+ init.signal = withTimeout(signal, API_TIMEOUT_MS);
96
+ // Resolve URL: prepend baseUrl if configured, normalizing double slashes at the join
97
+ let url;
98
+ if (cfg.baseUrl !== undefined) {
99
+ const base = cfg.baseUrl.endsWith("/") ? cfg.baseUrl.slice(0, -1) : cfg.baseUrl;
100
+ const path = spec.path.startsWith("/") ? spec.path : `/${spec.path}`;
101
+ url = `${base}${path}`;
102
+ }
103
+ else {
104
+ url = spec.path;
105
+ }
106
+ // Use custom fetchFn or global fetch
107
+ const fetchImpl = cfg.fetchFn ?? fetch;
108
+ let r;
109
+ try {
110
+ r = await fetchImpl(url, init);
111
+ }
112
+ catch (e) {
113
+ throw classifyFetchError(e, signal);
114
+ }
115
+ if (!r.ok) {
116
+ let serverError = "";
117
+ let serverCode;
118
+ try {
119
+ const body = await r.json();
120
+ if (hasErrorString(body)) {
121
+ serverError = body.error;
122
+ }
123
+ if (typeof body === "object" && body !== null && "code" in body) {
124
+ const code = body.code;
125
+ if (typeof code === "string") {
126
+ serverCode = code;
127
+ }
128
+ }
129
+ }
130
+ catch {
131
+ // Body wasn't JSON — leave serverError empty.
132
+ }
133
+ const opts = { status: r.status };
134
+ if (serverCode !== undefined) {
135
+ opts.code = serverCode;
136
+ }
137
+ throw new ActionError(serverError !== "" ? serverError : `HTTP ${String(r.status)}`, opts);
138
+ }
139
+ if (r.status === 204) {
140
+ return undefined;
141
+ }
142
+ const text = await r.text();
143
+ if (text === "") {
144
+ if (spec.method !== "DELETE") {
145
+ console.warn(`[actions] ${spec.method} ${spec.path} returned empty body — callers expecting data will receive undefined`);
146
+ }
147
+ return undefined;
148
+ }
149
+ try {
150
+ return JSON.parse(text);
151
+ }
152
+ catch (e) {
153
+ throw new ActionError(`response not JSON: ${e instanceof Error ? e.message : String(e)}`, {
154
+ status: r.status,
155
+ cause: e,
156
+ });
157
+ }
158
+ }
@@ -0,0 +1,11 @@
1
+ import type { Action } from "./types.js";
2
+ /** Internal: register an Action so cancelAllPending() can iterate it. */
3
+ export declare function _registerAction<TArgs, TResult>(action: Action<TArgs, TResult>): void;
4
+ /**
5
+ * Register a cleanup function to run on page unload (or test invoke).
6
+ */
7
+ export declare function registerCleanup(fn: () => void): () => void;
8
+ /** Test-only: invoke the same cleanup logic that beforeunload runs. */
9
+ export declare function _cancelAllForTest(): void;
10
+ /** Test-only: clear both registries + uninstall the listener. */
11
+ export declare function _resetForTest(): void;
@@ -0,0 +1,63 @@
1
+ // Global cleanup: cancel all in-flight actions + run registered
2
+ // cleanup hooks. Wired to window.beforeunload so navigation away
3
+ // from the page aborts everything cleanly.
4
+ //
5
+ // Cleanup hooks must be idempotent. Cancellation is allowed to fire
6
+ // multiple times (e.g., cancelled navigation followed by confirmed).
7
+ // ---------------------------------------------------------------------------
8
+ const trackedActions = new Set();
9
+ const cleanupHooks = new Set();
10
+ let beforeunloadInstalled = false;
11
+ /** Internal: register an Action so cancelAllPending() can iterate it. */
12
+ export function _registerAction(action) {
13
+ trackedActions.add(action);
14
+ installBeforeunloadOnce();
15
+ }
16
+ /**
17
+ * Register a cleanup function to run on page unload (or test invoke).
18
+ */
19
+ export function registerCleanup(fn) {
20
+ cleanupHooks.add(fn);
21
+ installBeforeunloadOnce();
22
+ return () => cleanupHooks.delete(fn);
23
+ }
24
+ function cancelAllPending() {
25
+ for (const action of trackedActions) {
26
+ try {
27
+ action.cancel();
28
+ }
29
+ catch (e) {
30
+ console.error(`[actions] cancel for ${action.name} threw`, e);
31
+ }
32
+ }
33
+ for (const fn of [...cleanupHooks]) {
34
+ try {
35
+ fn();
36
+ }
37
+ catch (e) {
38
+ console.error("[actions] cleanup hook threw", e);
39
+ }
40
+ }
41
+ }
42
+ function installBeforeunloadOnce() {
43
+ if (beforeunloadInstalled) {
44
+ return;
45
+ }
46
+ beforeunloadInstalled = true;
47
+ if (typeof window !== "undefined") {
48
+ window.addEventListener("beforeunload", cancelAllPending);
49
+ }
50
+ }
51
+ /** Test-only: invoke the same cleanup logic that beforeunload runs. */
52
+ export function _cancelAllForTest() {
53
+ cancelAllPending();
54
+ }
55
+ /** Test-only: clear both registries + uninstall the listener. */
56
+ export function _resetForTest() {
57
+ trackedActions.clear();
58
+ cleanupHooks.clear();
59
+ if (beforeunloadInstalled && typeof window !== "undefined") {
60
+ window.removeEventListener("beforeunload", cancelAllPending);
61
+ }
62
+ beforeunloadInstalled = false;
63
+ }
@@ -0,0 +1,28 @@
1
+ import type { Action } from "./types.js";
2
+ /** A debounced action dispatcher. Callable to schedule a dispatch,
3
+ * with `flush`, `cancel`, and `isPending` control methods. */
4
+ export interface DebouncedDispatch<TArgs> {
5
+ /** Schedule a dispatch with the given args. Replaces any pending
6
+ * dispatch's args. */
7
+ (args: TArgs): void;
8
+ /** Fire immediately with the most-recent args (or args supplied
9
+ * here, overriding the pending). No-op if nothing is pending and
10
+ * no args supplied. */
11
+ flush(args?: TArgs): Promise<unknown> | undefined;
12
+ /** Discard any pending dispatch without firing. */
13
+ cancel(): void;
14
+ /** True if there's a scheduled dispatch waiting for the timer. */
15
+ isPending(): boolean;
16
+ }
17
+ interface DebounceOptions {
18
+ /** Quiet window in ms. */
19
+ readonly wait: number;
20
+ /** Fire on the leading edge instead of the trailing edge. Default false. */
21
+ readonly leading?: boolean;
22
+ }
23
+ /**
24
+ * Wrap an action with a debounce timer so rapid calls coalesce into a
25
+ * single dispatch after a quiet window.
26
+ */
27
+ export declare function debouncedDispatch<TArgs, TResult>(action: Action<TArgs, TResult>, opts: DebounceOptions): DebouncedDispatch<TArgs>;
28
+ export {};
@@ -0,0 +1,91 @@
1
+ // debouncedDispatch: wrap an Action so that rapid calls coalesce into
2
+ // a single dispatch after a quiet window. Replaces ad-hoc setTimeout
3
+ // + clearTimeout chains with a single helper that adds flush/cancel.
4
+ // ---------------------------------------------------------------------------
5
+ /**
6
+ * Wrap an action with a debounce timer so rapid calls coalesce into a
7
+ * single dispatch after a quiet window.
8
+ */
9
+ export function debouncedDispatch(action, opts) {
10
+ let timer;
11
+ let lastArgs;
12
+ let pending = false;
13
+ let lastFiredAt = 0;
14
+ const fn = ((args) => {
15
+ if (opts.leading === true) {
16
+ const now = Date.now();
17
+ if (now - lastFiredAt < opts.wait) {
18
+ lastArgs = args;
19
+ pending = true;
20
+ if (timer === undefined) {
21
+ const remaining = Math.max(0, opts.wait - (now - lastFiredAt));
22
+ timer = setTimeout(fireTrailing, remaining);
23
+ }
24
+ return;
25
+ }
26
+ void action.dispatch(args);
27
+ lastFiredAt = now;
28
+ lastArgs = undefined;
29
+ pending = true;
30
+ if (timer !== undefined) {
31
+ clearTimeout(timer);
32
+ }
33
+ timer = setTimeout(fireTrailing, opts.wait);
34
+ return;
35
+ }
36
+ lastArgs = args;
37
+ pending = true;
38
+ if (timer !== undefined) {
39
+ clearTimeout(timer);
40
+ }
41
+ timer = setTimeout(() => {
42
+ timer = undefined;
43
+ pending = false;
44
+ const a = lastArgs;
45
+ lastArgs = undefined;
46
+ if (a !== undefined) {
47
+ void action.dispatch(a);
48
+ }
49
+ }, opts.wait);
50
+ });
51
+ function fireTrailing() {
52
+ timer = undefined;
53
+ const a = lastArgs;
54
+ lastArgs = undefined;
55
+ if (a !== undefined) {
56
+ lastFiredAt = Date.now();
57
+ pending = true;
58
+ timer = setTimeout(fireTrailing, opts.wait);
59
+ void action.dispatch(a);
60
+ }
61
+ else {
62
+ pending = false;
63
+ }
64
+ }
65
+ fn.flush = (args) => {
66
+ if (timer !== undefined) {
67
+ clearTimeout(timer);
68
+ timer = undefined;
69
+ }
70
+ const a = args ?? lastArgs;
71
+ lastArgs = undefined;
72
+ pending = false;
73
+ if (a !== undefined) {
74
+ if (opts.leading === true) {
75
+ lastFiredAt = Date.now();
76
+ }
77
+ return action.dispatch(a);
78
+ }
79
+ return undefined;
80
+ };
81
+ fn.cancel = () => {
82
+ if (timer !== undefined) {
83
+ clearTimeout(timer);
84
+ timer = undefined;
85
+ }
86
+ lastArgs = undefined;
87
+ pending = false;
88
+ };
89
+ fn.isPending = () => pending;
90
+ return fn;
91
+ }
@@ -0,0 +1,20 @@
1
+ import type { NotificationSpec } from "./types.js";
2
+ /** Invoke a callback safely — errors are caught and logged without
3
+ * disrupting the dispatch lifecycle. */
4
+ export declare function safeInvoke(actionName: string, hookName: string, fn: () => void): void;
5
+ export declare const _symbolMap: Map<symbol, number>;
6
+ export declare function symbolId(sym: symbol): number;
7
+ /** Reset symbol state — test-only. */
8
+ export declare function _resetSymbols(): void;
9
+ /** Defensive JSON.stringify — falls back to String(args) on cycles
10
+ * or non-serializable values (DOM elements, functions). Used by
11
+ * the default dedupe key computation. */
12
+ export declare function safeStringify(args: unknown): string;
13
+ /** Resolve a NotificationSpec to its message string. Returns null when
14
+ * the spec is `false` (suppressed) or undefined and no fallback. */
15
+ export declare function resolveNotification<TArgs, TPayload>(spec: NotificationSpec<TArgs, TPayload> | undefined, args: TArgs, payload: TPayload, fallback?: string): string | null;
16
+ /** @deprecated Use {@link resolveNotification} instead. */
17
+ export declare const resolveToast: typeof resolveNotification;
18
+ /** Build a default error notification prefix from the action name.
19
+ * Converts "chat.delete" -> "Delete failed". */
20
+ export declare function defaultErrorPrefix(name: string): string;