@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "exports": {
5
5
  ".": "./src/index.ts",
6
6
  "./testing": "./src/testing.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "2.0.3",
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.5.0",
22
+ "eslint": "10.6.0",
23
23
  "fast-check": "4.8.0",
24
24
  "happy-dom": "20.10.6",
25
- "prettier": "3.8.4",
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
- headers.forEach((v, k) => {
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) {
@@ -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);
@@ -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
- export const _symbolMap = new Map<symbol, number>();
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
- return JSON.stringify(args, (_key, value: unknown) =>
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 rnd = Math.random().toString(36).slice(2, 16).padEnd(14, "0");
54
- return `${ts}-${rnd}`;
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
- const onSettledCb = opts.onSettled;
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({ message: "deduped dispatch did not succeed", code: "dedupe" }, args);
166
+ onErrorCb(dedupeErr, args);
151
167
  });
152
168
  }
153
169
  }
154
- const onSettledCb = opts.onSettled;
155
- if (onSettledCb) {
156
- safeInvoke(def.name, "onSettled", () => {
157
- onSettledCb(args);
158
- });
159
- }
170
+ fireSettledHooks();
160
171
  return v;
161
172
  },
162
173
  () => {
163
- const onSettledCb = opts.onSettled;
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
- fireDefSettled(args);
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
- | HTMLButtonElement
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 only if run() throws. */
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. */