@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/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# actions
|
|
2
|
+
|
|
3
|
+
[](https://github.com/cplieger/actions/actions/workflows/ci.yaml)
|
|
4
|
+
[](https://www.npmjs.com/package/@cplieger/actions)
|
|
5
|
+
[](https://jsr.io/@cplieger/actions)
|
|
6
|
+
[](./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>;
|
package/dist/src/api.js
ADDED
|
@@ -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;
|