@cplieger/actions 2.0.3 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/jsr.json +1 -1
- package/package.json +3 -3
- package/src/api.ts +18 -4
- package/src/async-feedback.ts +20 -0
- package/src/define-helpers.ts +8 -5
- package/src/define.ts +34 -31
- package/src/loading.ts +1 -4
- package/src/notifier.ts +7 -1
- package/src/poll.ts +2 -1
- package/src/registry.ts +6 -1
- package/src/types.ts +4 -4
package/README.md
CHANGED
|
@@ -76,6 +76,8 @@ Options (mirrors RTK `fetchBaseQuery`):
|
|
|
76
76
|
- `credentials` — `RequestInit.credentials` mode (e.g. `"include"` for cookies)
|
|
77
77
|
- `fetchFn` — custom fetch implementation (SSR, testing)
|
|
78
78
|
|
|
79
|
+
> **Path contract:** `RequestSpec.path` is expected to be a **relative** path. With `baseUrl` set, the configured scheme+host always precede it, so an absolute (`https://…`) or protocol-relative (`//host`) path is neutralised (kept as a path segment) and cannot override the origin. With `baseUrl` **unset**, `path` is passed to `fetch()` verbatim — the caller owns the full URL and must never pass untrusted input (e.g. a server-supplied string) as the whole path.
|
|
80
|
+
|
|
79
81
|
Per-request headers can also be set directly on `RequestSpec`:
|
|
80
82
|
|
|
81
83
|
```typescript
|
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/actions",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"@eslint/js": "10.0.1",
|
|
20
20
|
"@types/node": "24.13.2",
|
|
21
21
|
"@vitest/coverage-v8": "4.1.9",
|
|
22
|
-
"eslint": "10.
|
|
22
|
+
"eslint": "10.6.0",
|
|
23
23
|
"fast-check": "4.8.0",
|
|
24
24
|
"happy-dom": "20.10.6",
|
|
25
|
-
"prettier": "3.
|
|
25
|
+
"prettier": "3.9.0",
|
|
26
26
|
"typescript-eslint": "8.62.0",
|
|
27
27
|
"vitest": "4.1.9"
|
|
28
28
|
},
|
package/src/api.ts
CHANGED
|
@@ -32,7 +32,11 @@ const JSON_CT = "application/json";
|
|
|
32
32
|
|
|
33
33
|
/** Configuration for the global API fetch layer. Set via `configureApi()`. */
|
|
34
34
|
export interface ApiConfig {
|
|
35
|
-
/** Base URL prepended to every RequestSpec.path (e.g. "https://api.example.com/v1").
|
|
35
|
+
/** Base URL prepended to every RequestSpec.path (e.g. "https://api.example.com/v1").
|
|
36
|
+
* `RequestSpec.path` is treated as a RELATIVE path: when this is set, an absolute or
|
|
37
|
+
* protocol-relative path cannot override the origin (it is kept as a path segment).
|
|
38
|
+
* When this is UNSET, `RequestSpec.path` is passed to fetch() verbatim, so the caller
|
|
39
|
+
* owns the full URL and must never pass untrusted input as the whole path. */
|
|
36
40
|
readonly baseUrl?: string;
|
|
37
41
|
/** Inject headers on every request. Receives current headers + the request spec.
|
|
38
42
|
* Mutate and/or return the headers object. May be async (e.g. to read a token store). */
|
|
@@ -124,13 +128,18 @@ async function executeRequest<T>(
|
|
|
124
128
|
headers.set(k, v);
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
|
-
// Global prepareHeaders hook
|
|
131
|
+
// Global prepareHeaders hook. Honor a returned Headers (RTK convention), falling
|
|
132
|
+
// back to the mutated instance when the hook returns undefined.
|
|
133
|
+
let effectiveHeaders = headers;
|
|
128
134
|
if (cfg.prepareHeaders !== undefined) {
|
|
129
|
-
await cfg.prepareHeaders(headers, { spec });
|
|
135
|
+
const prepared = await cfg.prepareHeaders(headers, { spec });
|
|
136
|
+
if (prepared !== undefined) {
|
|
137
|
+
effectiveHeaders = prepared;
|
|
138
|
+
}
|
|
130
139
|
}
|
|
131
140
|
// Convert Headers to plain object for RequestInit
|
|
132
141
|
const headerObj: Record<string, string> = {};
|
|
133
|
-
|
|
142
|
+
effectiveHeaders.forEach((v, k) => {
|
|
134
143
|
headerObj[k.toLowerCase()] = v;
|
|
135
144
|
});
|
|
136
145
|
if (Object.keys(headerObj).length > 0) {
|
|
@@ -144,6 +153,11 @@ async function executeRequest<T>(
|
|
|
144
153
|
|
|
145
154
|
init.signal = withTimeout(signal, API_TIMEOUT_MS);
|
|
146
155
|
|
|
156
|
+
// CONTRACT: spec.path is a RELATIVE path. With baseUrl set, the base scheme+host
|
|
157
|
+
// precede it, so an absolute ('https://...') or protocol-relative ('//host') path is
|
|
158
|
+
// neutralised (kept as a path segment) and cannot override the origin. With baseUrl
|
|
159
|
+
// UNSET, spec.path is passed to fetch() verbatim, so the caller owns the full URL and
|
|
160
|
+
// must never pass untrusted input (e.g. a server-supplied string) as the whole path.
|
|
147
161
|
// Resolve URL: prepend baseUrl if configured, normalizing double slashes at the join
|
|
148
162
|
let url: string;
|
|
149
163
|
if (cfg.baseUrl !== undefined) {
|
package/src/async-feedback.ts
CHANGED
|
@@ -162,6 +162,10 @@ export async function withAsyncFeedback(
|
|
|
162
162
|
const origNodes = useTarget ? [] : [...btn.childNodes].map((n) => n.cloneNode(true));
|
|
163
163
|
const origDisabled = btn.disabled;
|
|
164
164
|
const origAriaBusy = btn.getAttribute("aria-busy");
|
|
165
|
+
// Capture focus BEFORE disabling: `btn.disabled = true` (below) blurs the
|
|
166
|
+
// button, moving focus to <body>. We restore it once the button re-enables
|
|
167
|
+
// so a keyboard user who activated it does not lose their place.
|
|
168
|
+
const hadFocus = document.activeElement === btn;
|
|
165
169
|
|
|
166
170
|
const restoreAriaBusy = (): void => {
|
|
167
171
|
if (origAriaBusy === null) {
|
|
@@ -171,6 +175,20 @@ export async function withAsyncFeedback(
|
|
|
171
175
|
}
|
|
172
176
|
};
|
|
173
177
|
|
|
178
|
+
// Restore keyboard focus to the button after it re-enables, but only if it
|
|
179
|
+
// held focus before being disabled, is still connected and focusable, and
|
|
180
|
+
// focus has not since moved to a competing element (activeElement is <body>
|
|
181
|
+
// or null). Mirrors loading.ts's bindLoadingState/setIdle guard so the two
|
|
182
|
+
// helpers behave consistently and neither steals focus the user moved away.
|
|
183
|
+
const restoreFocus = (): void => {
|
|
184
|
+
if (hadFocus && btn.isConnected && !btn.disabled) {
|
|
185
|
+
const active = document.activeElement;
|
|
186
|
+
if (active === null || active === document.body) {
|
|
187
|
+
btn.focus();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
174
192
|
btn.dataset["asyncStatus"] = "pending";
|
|
175
193
|
btn.disabled = true;
|
|
176
194
|
btn.setAttribute("aria-busy", "true");
|
|
@@ -217,6 +235,7 @@ export async function withAsyncFeedback(
|
|
|
217
235
|
const reset = opts.resetMs ?? RESET_MS;
|
|
218
236
|
if (reset <= 0) {
|
|
219
237
|
btn.disabled = origDisabled;
|
|
238
|
+
restoreFocus();
|
|
220
239
|
return;
|
|
221
240
|
}
|
|
222
241
|
|
|
@@ -234,6 +253,7 @@ export async function withAsyncFeedback(
|
|
|
234
253
|
btn.replaceChildren(...origNodes.map((n) => n.cloneNode(true)));
|
|
235
254
|
}
|
|
236
255
|
btn.disabled = origDisabled;
|
|
256
|
+
restoreFocus();
|
|
237
257
|
delete btn.dataset["asyncStatus"];
|
|
238
258
|
}, reset);
|
|
239
259
|
resetTimers.set(btn, timerId);
|
package/src/define-helpers.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function safeInvoke(actionName: string, hookName: string, fn: () => void)
|
|
|
19
19
|
* the same description are distinct values but String(sym) is identical,
|
|
20
20
|
* so we assign each unique symbol a stable numeric ID. */
|
|
21
21
|
let _symbolCounter = 0;
|
|
22
|
-
|
|
22
|
+
const _symbolMap = new Map<symbol, number>();
|
|
23
23
|
export function symbolId(sym: symbol): number {
|
|
24
24
|
let id = _symbolMap.get(sym);
|
|
25
25
|
if (id === undefined) {
|
|
@@ -55,9 +55,15 @@ export function safeStringify(args: unknown): string {
|
|
|
55
55
|
return `@@sym${String(symbolId(args))}`;
|
|
56
56
|
}
|
|
57
57
|
try {
|
|
58
|
-
|
|
58
|
+
const out = JSON.stringify(args, (_key, value: unknown) =>
|
|
59
59
|
value === undefined ? "__undef__" : value,
|
|
60
60
|
);
|
|
61
|
+
// A top-level non-serializable value (a bare function) makes JSON.stringify
|
|
62
|
+
// return the value `undefined` rather than throw, so the catch never fires.
|
|
63
|
+
// Coerce to a stable string so distinct function args don't collide on a
|
|
64
|
+
// single `${name}::undefined` dedupe key.
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- intentional fallback for non-serializable values
|
|
66
|
+
return typeof out === "string" ? out : String(args);
|
|
61
67
|
} catch {
|
|
62
68
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- intentional fallback for cyclic objects
|
|
63
69
|
return String(args);
|
|
@@ -84,9 +90,6 @@ export function resolveNotification<TArgs, TPayload>(
|
|
|
84
90
|
return spec(args, payload);
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
/** @deprecated Use {@link resolveNotification} instead. */
|
|
88
|
-
export const resolveToast = resolveNotification;
|
|
89
|
-
|
|
90
93
|
/** Build a default error notification prefix from the action name.
|
|
91
94
|
* Converts "chat.delete" -> "Delete failed". */
|
|
92
95
|
export function defaultErrorPrefix(name: string): string {
|
package/src/define.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { _registerAction } from "./cleanup.js";
|
|
|
10
10
|
import {
|
|
11
11
|
safeInvoke,
|
|
12
12
|
safeStringify,
|
|
13
|
-
_symbolMap,
|
|
14
13
|
_resetSymbols,
|
|
15
14
|
resolveNotification,
|
|
16
15
|
defaultErrorPrefix,
|
|
@@ -50,8 +49,13 @@ export const IDEMPOTENCY_HEADER = "Idempotency-Key";
|
|
|
50
49
|
|
|
51
50
|
function generateIdempotencyKey(): string {
|
|
52
51
|
const ts = Date.now().toString(36);
|
|
53
|
-
const
|
|
54
|
-
|
|
52
|
+
const buf = new Uint8Array(10);
|
|
53
|
+
crypto.getRandomValues(buf);
|
|
54
|
+
let rnd = "";
|
|
55
|
+
for (const b of buf) {
|
|
56
|
+
rnd += b.toString(36).padStart(2, "0");
|
|
57
|
+
}
|
|
58
|
+
return `${ts}-${rnd.slice(0, 14)}`;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const scopeChains = new Map<string, Promise<unknown>>();
|
|
@@ -67,6 +71,7 @@ interface DedupeSlot {
|
|
|
67
71
|
promise: Promise<unknown> | undefined;
|
|
68
72
|
error?: ActionErrorLike;
|
|
69
73
|
cancelled?: boolean;
|
|
74
|
+
succeeded?: boolean;
|
|
70
75
|
}
|
|
71
76
|
const activeDedupes = new Map<string, DedupeSlot>();
|
|
72
77
|
|
|
@@ -112,31 +117,37 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
|
|
|
112
117
|
args: TArgs,
|
|
113
118
|
opts: DispatchOptions<TArgs, TResult> = NO_OPTS,
|
|
114
119
|
): DispatchHandle<TResult> {
|
|
120
|
+
const fireSettledHooks = (): void => {
|
|
121
|
+
fireDefSettled(args);
|
|
122
|
+
const onSettledCb = opts.onSettled;
|
|
123
|
+
if (onSettledCb) {
|
|
124
|
+
safeInvoke(def.name, "onSettled", () => {
|
|
125
|
+
onSettledCb(args);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
};
|
|
115
129
|
const dedupeKey = dedupeKeyFor(args);
|
|
116
130
|
if (dedupeKey !== null) {
|
|
117
131
|
const entry = activeDedupes.get(dedupeKey);
|
|
118
132
|
if (entry !== undefined) {
|
|
119
133
|
const shared = entry.promise;
|
|
120
134
|
if (shared === undefined) {
|
|
121
|
-
|
|
122
|
-
if (onSettledCb) {
|
|
123
|
-
safeInvoke(def.name, "onSettled", () => {
|
|
124
|
-
onSettledCb(args);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
135
|
+
fireSettledHooks();
|
|
127
136
|
return makeHandle(Promise.resolve(null) as Promise<TResult | null>, NOOP);
|
|
128
137
|
}
|
|
129
138
|
const joined = (shared as Promise<TResult | null>).then(
|
|
130
139
|
(v) => {
|
|
131
|
-
if (v !== null) {
|
|
140
|
+
if (v !== null || entry.succeeded === true) {
|
|
141
|
+
fireDefSuccess(v as TResult, args);
|
|
132
142
|
const onSuccessCb = opts.onSuccess;
|
|
133
143
|
if (onSuccessCb) {
|
|
134
144
|
safeInvoke(def.name, "onSuccess", () => {
|
|
135
|
-
onSuccessCb(v, args);
|
|
145
|
+
onSuccessCb(v as TResult, args);
|
|
136
146
|
});
|
|
137
147
|
}
|
|
138
148
|
} else if (entry.error !== undefined) {
|
|
139
149
|
const capturedErr = entry.error;
|
|
150
|
+
fireDefError(capturedErr, args);
|
|
140
151
|
const onErrorCb = opts.onError;
|
|
141
152
|
if (onErrorCb) {
|
|
142
153
|
safeInvoke(def.name, "onError", () => {
|
|
@@ -144,28 +155,23 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
|
|
|
144
155
|
});
|
|
145
156
|
}
|
|
146
157
|
} else if (entry.cancelled !== true) {
|
|
158
|
+
const dedupeErr: ActionErrorLike = {
|
|
159
|
+
message: "deduped dispatch did not succeed",
|
|
160
|
+
code: "dedupe",
|
|
161
|
+
};
|
|
162
|
+
fireDefError(dedupeErr, args);
|
|
147
163
|
const onErrorCb = opts.onError;
|
|
148
164
|
if (onErrorCb) {
|
|
149
165
|
safeInvoke(def.name, "onError", () => {
|
|
150
|
-
onErrorCb(
|
|
166
|
+
onErrorCb(dedupeErr, args);
|
|
151
167
|
});
|
|
152
168
|
}
|
|
153
169
|
}
|
|
154
|
-
|
|
155
|
-
if (onSettledCb) {
|
|
156
|
-
safeInvoke(def.name, "onSettled", () => {
|
|
157
|
-
onSettledCb(args);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
170
|
+
fireSettledHooks();
|
|
160
171
|
return v;
|
|
161
172
|
},
|
|
162
173
|
() => {
|
|
163
|
-
|
|
164
|
-
if (onSettledCb) {
|
|
165
|
-
safeInvoke(def.name, "onSettled", () => {
|
|
166
|
-
onSettledCb(args);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
174
|
+
fireSettledHooks();
|
|
169
175
|
return null;
|
|
170
176
|
},
|
|
171
177
|
);
|
|
@@ -234,13 +240,7 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
|
|
|
234
240
|
completedAt: now,
|
|
235
241
|
});
|
|
236
242
|
} finally {
|
|
237
|
-
|
|
238
|
-
const onSettledCb = opts.onSettled;
|
|
239
|
-
if (onSettledCb) {
|
|
240
|
-
safeInvoke(def.name, "onSettled", () => {
|
|
241
|
-
onSettledCb(args);
|
|
242
|
-
});
|
|
243
|
-
}
|
|
243
|
+
fireSettledHooks();
|
|
244
244
|
earlyCancelResolve(null);
|
|
245
245
|
}
|
|
246
246
|
});
|
|
@@ -426,6 +426,9 @@ export function defineAction<TArgs, TResult, TOp = unknown>(
|
|
|
426
426
|
result,
|
|
427
427
|
attempts,
|
|
428
428
|
});
|
|
429
|
+
if (dedupeEntry !== null) {
|
|
430
|
+
dedupeEntry.succeeded = true;
|
|
431
|
+
}
|
|
429
432
|
evictDedupeSlot(dedupeKey, dedupeEntry);
|
|
430
433
|
emitSuccessToast(args, result, opts);
|
|
431
434
|
fireDefSuccess(result, args);
|
package/src/loading.ts
CHANGED
|
@@ -18,10 +18,7 @@ import { isPending, pendingCount } from "./registry.js";
|
|
|
18
18
|
|
|
19
19
|
/** Element types that have a `.disabled` writable boolean. */
|
|
20
20
|
type DisableableElement =
|
|
21
|
-
|
|
|
22
|
-
| HTMLInputElement
|
|
23
|
-
| HTMLSelectElement
|
|
24
|
-
| HTMLTextAreaElement;
|
|
21
|
+
HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
25
22
|
|
|
26
23
|
interface BindLoadingOptions {
|
|
27
24
|
ariaBusy?: boolean;
|
package/src/notifier.ts
CHANGED
|
@@ -12,7 +12,13 @@ export interface NotifierRetry {
|
|
|
12
12
|
* and pass it to `configure()` to wire up toast/notification display.
|
|
13
13
|
*
|
|
14
14
|
* All methods are optional — when not provided, the framework silently
|
|
15
|
-
* drops the notification (useful for headless/test environments).
|
|
15
|
+
* drops the notification (useful for headless/test environments).
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: the `message` passed to `error()` (and `success()`) may contain
|
|
18
|
+
* server-controlled text — e.g. an HTTP error body's `error` field surfaced via
|
|
19
|
+
* ActionError.message, or a transport `r.error`. Render it as TEXT (textContent /
|
|
20
|
+
* a text node), never via innerHTML, to avoid reflected XSS from a malicious or
|
|
21
|
+
* compromised server response. */
|
|
16
22
|
export interface Notifier {
|
|
17
23
|
success?(message: string): void;
|
|
18
24
|
error?(message: string, retry?: NotifierRetry): void;
|
package/src/poll.ts
CHANGED
|
@@ -126,6 +126,7 @@ export function pollAction<TArgs, TResult>(
|
|
|
126
126
|
timer = undefined;
|
|
127
127
|
}
|
|
128
128
|
listenerCtrl.abort();
|
|
129
|
+
unregisterCleanup();
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
const listenerCtrl = new AbortController();
|
|
@@ -140,7 +141,7 @@ export function pollAction<TArgs, TResult>(
|
|
|
140
141
|
window.addEventListener("focus", onFocus, { signal: listenerCtrl.signal });
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
registerCleanup(stop);
|
|
144
|
+
const unregisterCleanup = registerCleanup(stop);
|
|
144
145
|
|
|
145
146
|
if (!paused) {
|
|
146
147
|
void tick();
|
package/src/registry.ts
CHANGED
|
@@ -222,7 +222,12 @@ export function recentLog(): readonly ActionInstance[] {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
/** Read the recent action log. Useful for devtools integration and
|
|
225
|
-
* debugging panels. Returns a snapshot of all live entries.
|
|
225
|
+
* debugging panels. Returns a snapshot of all live entries.
|
|
226
|
+
*
|
|
227
|
+
* SECURITY/PRIVACY: each entry retains the full dispatch `args` in memory (up to
|
|
228
|
+
* MAX_LOG_SIZE entries), `subscribeToActions` fans `args` out to every listener, and
|
|
229
|
+
* buildRetryButton retains a structuredClone of `args` in the error-notification
|
|
230
|
+
* retry closure. Do NOT put secrets, tokens, or PII in action args. */
|
|
226
231
|
export const getActionLog = recentLog;
|
|
227
232
|
|
|
228
233
|
/** O(1) check: true if at least one instance of the named action is pending.
|
package/src/types.ts
CHANGED
|
@@ -50,9 +50,7 @@ export interface ActionInstance<TArgs = unknown, TResult = unknown> {
|
|
|
50
50
|
* computed from action args + result/error at call time. Pass false
|
|
51
51
|
* to opt out of the default notification for that branch. */
|
|
52
52
|
export type NotificationSpec<TArgs, TPayload> =
|
|
53
|
-
| string
|
|
54
|
-
| ((args: TArgs, payload: TPayload) => string)
|
|
55
|
-
| false;
|
|
53
|
+
string | ((args: TArgs, payload: TPayload) => string) | false;
|
|
56
54
|
|
|
57
55
|
/** @deprecated Use {@link NotificationSpec} instead. */
|
|
58
56
|
export type ToastSpec<TArgs, TPayload> = NotificationSpec<TArgs, TPayload>;
|
|
@@ -97,7 +95,9 @@ export interface ActionDefinition<TArgs, TResult, TOp = unknown> {
|
|
|
97
95
|
/** Optional optimistic mutation. Runs synchronously before run(). */
|
|
98
96
|
optimistic?: (args: TArgs) => TOp | undefined;
|
|
99
97
|
|
|
100
|
-
/** Undo the optimistic mutation. Called
|
|
98
|
+
/** Undo the optimistic mutation. Called on error OR cancellation (including a
|
|
99
|
+
* cancellation that lands after run() resolves), never on success. The `err`
|
|
100
|
+
* carries {code:'cancelled'} on the cancellation path. */
|
|
101
101
|
rollback?: (args: TArgs, op: TOp | undefined, err: ActionErrorLike) => void;
|
|
102
102
|
|
|
103
103
|
/** Notification on success. Default: no notification. */
|