@cplieger/actions 2.0.6 → 2.0.8

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 (4) hide show
  1. package/README.md +12 -8
  2. package/jsr.json +1 -1
  3. package/package.json +10 -7
  4. package/src/api.ts +102 -91
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  > Declarative UI-actions framework with lifecycle management, retry, debounce, and polling.
10
10
 
11
- 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, polling, button-feedback helpers, and a registry for observability. Built on [`@cplieger/reactive`](https://github.com/cplieger/reactive) — action pending-state is signal-backed, so `isPending`/`pendingCount` are reactive and `bindLoadingState` is a plain effect over them. Notification display and streaming transport are injected by the consumer via small interfaces.
11
+ 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, polling, button-feedback helpers, and a registry for observability. Built on [`@cplieger/reactive`](https://github.com/cplieger/reactive) — action pending-state is signal-backed, so `isPending`/`pendingCount` are reactive and `bindLoadingState` is a plain effect over them. HTTP-backed `apiAction`s route their requests through [`@cplieger/fetch`](https://github.com/cplieger/fetch), the toolkit's shared request core (base URL, credentials, header prep, and a custom `fetchFn` are injected via `configureApi`). Notification display and streaming transport are injected by the consumer via small interfaces.
12
12
 
13
13
  ## Install
14
14
 
@@ -134,13 +134,11 @@ beforeEach(() => {
134
134
  - `resetActionFramework()` — clear every framework state slot (define, registry, cleanup, api, transport, notifier). Call from `beforeEach()` to isolate tests.
135
135
 
136
136
  > **Breaking change in v2.0:** the `./src/*` deep-import escape hatch was removed
137
- > from `package.json` exports. Consumers that previously reached into
138
- > `@cplieger/actions/src/define`, `…/src/registry`, `…/src/cleanup`,
139
- > `…/src/api`, `…/src/transport`, or `…/src/notifier` to call
140
- > `_resetForTest`/`_resetApiConfigForTest`/`_resetTransportForTest`/
141
- > `_resetNotifierForTest` must migrate to
142
- > `@cplieger/actions/testing` for `resetActionFramework()`, or to the
143
- > public surface for everything else.
137
+ > from `package.json` exports. Migrate any deep `/src/*` import to
138
+ > `@cplieger/actions/testing` (`resetActionFramework()`), or to the public API
139
+ > for everything else. The [release notes](https://github.com/cplieger/actions/releases)
140
+ > list the removed subpaths and the old `_reset*ForTest` helpers that
141
+ > `resetActionFramework()` supersedes.
144
142
 
145
143
  ### Definition-level callbacks (TanStack Query pattern)
146
144
 
@@ -199,6 +197,12 @@ The following features are intentionally not implemented:
199
197
  | Batch dispatch | Store-level concern. This library doesn't own a store. |
200
198
  | `dispose()` / action deregistration | Actions are lightweight when idle. Not a leak concern for realistic app sizes. |
201
199
 
200
+ ## Disclaimer
201
+
202
+ This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
203
+
204
+ This project was built with AI-assisted tooling using [Claude Opus](https://www.anthropic.com/claude) and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
205
+
202
206
  ## License
203
207
 
204
208
  GPL-3.0 — see [LICENSE](LICENSE).
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
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.6",
3
+ "version": "2.0.8",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -8,23 +8,26 @@
8
8
  "./testing": "./src/testing.ts"
9
9
  },
10
10
  "scripts": {
11
- "typecheck": "tsgo -project tsconfig.json",
12
- "typecheck:tests": "tsgo -project tsconfig.test.json",
11
+ "typecheck": "tsc -project tsconfig.json",
12
+ "typecheck:tests": "tsc -project tsconfig.test.json",
13
13
  "test": "vitest --run"
14
14
  },
15
15
  "dependencies": {
16
+ "@cplieger/fetch": "1.1.0",
16
17
  "@cplieger/reactive": "1.2.2"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@eslint/js": "10.0.1",
20
21
  "@types/node": "24.13.2",
21
- "@vitest/coverage-v8": "4.1.9",
22
+ "@typescript/native": "npm:typescript@7.0.2",
23
+ "@vitest/coverage-v8": "4.1.10",
22
24
  "eslint": "10.6.0",
23
25
  "fast-check": "4.8.0",
24
26
  "happy-dom": "20.10.6",
25
- "prettier": "3.9.1",
26
- "typescript-eslint": "8.62.0",
27
- "vitest": "4.1.9"
27
+ "prettier": "3.9.4",
28
+ "typescript": "npm:@typescript/typescript6@6.0.2",
29
+ "typescript-eslint": "8.63.0",
30
+ "vitest": "4.1.10"
28
31
  },
29
32
  "description": "Declarative async UI-action framework for TypeScript",
30
33
  "license": "GPL-3.0-or-later",
package/src/api.ts CHANGED
@@ -1,9 +1,16 @@
1
- // apiAction: factory for HTTP-backed actions. Wraps fetch so the run()
2
- // implementation is just the request descriptor.
1
+ // apiAction: factory for HTTP-backed actions. The run() implementation is just
2
+ // a request descriptor (RequestSpec); the request/response envelope is owned by
3
+ // @cplieger/fetch. actions holds its OWN isolated fetch instance (createFetch)
4
+ // so configureApi() never collides with a consuming app's global configureFetch,
5
+ // then maps fetch's ApiResult envelope onto ActionError so callers see the
6
+ // identical typed errors they always have.
3
7
  // ---------------------------------------------------------------------------
4
8
 
9
+ import { createFetch } from "@cplieger/fetch";
10
+ import type { ApiErr, FetchConfig, FetchInstance, RequestOptions } from "@cplieger/fetch";
11
+
5
12
  import { defineAction, IDEMPOTENCY_HEADER } from "./define.js";
6
- import { ActionError, classifyFetchError, hasErrorString } from "./error.js";
13
+ import { ActionError } from "./error.js";
7
14
  import type { Action, ActionContext, ActionDefinition, RequestSpec } from "./types.js";
8
15
 
9
16
  /** Default request timeout in milliseconds. */
@@ -50,7 +57,16 @@ export interface ApiConfig {
50
57
  readonly fetchFn?: typeof fetch;
51
58
  }
52
59
 
53
- let _apiConfig: ApiConfig = {};
60
+ // actions owns a PRIVATE, isolated @cplieger/fetch instance rather than the
61
+ // library's module-global configureFetch: a consuming app (subflux/vibekit) may
62
+ // call configureFetch() for its own direct fetch usage, and actions must never
63
+ // read or clobber that shared default. baseUrl / credentials / fetchFn are
64
+ // projected onto this instance. prepareHeaders stays here (not on the instance)
65
+ // because actions' hook is spec-aware — `(headers, { spec })` — a shape fetch's
66
+ // `(headers)`-only hook can't express, so executeRequest runs it itself and
67
+ // passes the result as the per-request headers.
68
+ let apiFetch: FetchInstance = createFetch();
69
+ let apiPrepareHeaders: ApiConfig["prepareHeaders"];
54
70
 
55
71
  /**
56
72
  * Configure the global HTTP layer used by all `apiAction` instances.
@@ -68,12 +84,26 @@ let _apiConfig: ApiConfig = {};
68
84
  * ```
69
85
  */
70
86
  export function configureApi(config: ApiConfig): void {
71
- _apiConfig = config;
87
+ const fetchConfig: FetchConfig = {};
88
+ if (config.baseUrl !== undefined) {
89
+ fetchConfig.baseUrl = config.baseUrl;
90
+ }
91
+ if (config.credentials !== undefined) {
92
+ fetchConfig.credentials = config.credentials;
93
+ }
94
+ if (config.fetchFn !== undefined) {
95
+ fetchConfig.fetchFn = config.fetchFn;
96
+ }
97
+ // Rebuild the instance so this call REPLACES the previous config (a bare
98
+ // instance.configure() would shallow-merge/accumulate instead).
99
+ apiFetch = createFetch(fetchConfig);
100
+ apiPrepareHeaders = config.prepareHeaders;
72
101
  }
73
102
 
74
103
  /** Reset API config. @internal Test-only. */
75
104
  export function _resetApiConfigForTest(): void {
76
- _apiConfig = {};
105
+ apiFetch = createFetch();
106
+ apiPrepareHeaders = undefined;
77
107
  }
78
108
 
79
109
  // ---------------------------------------------------------------------------
@@ -89,8 +119,9 @@ export interface ApiActionDefinition<TArgs, TResult, TOp = unknown> extends Omit
89
119
 
90
120
  /**
91
121
  * Build an Action from an HTTP request descriptor.
92
- * Wraps `defineAction` with a generated `run()` that calls `fetch`
93
- * via the global {@link ApiConfig} layer configured with {@link configureApi}.
122
+ * Wraps `defineAction` with a generated `run()` that dispatches the
123
+ * {@link RequestSpec} through the {@link ApiConfig} layer configured with
124
+ * {@link configureApi} (backed by an isolated `@cplieger/fetch` instance).
94
125
  */
95
126
  export function apiAction<TArgs, TResult = unknown, TOp = unknown>(
96
127
  def: ApiActionDefinition<TArgs, TResult, TOp>,
@@ -110,114 +141,94 @@ async function executeRequest<T>(
110
141
  signal: AbortSignal,
111
142
  ctx?: ActionContext,
112
143
  ): Promise<T> {
113
- const cfg = _apiConfig;
114
- const init: RequestInit = { method: spec.method };
115
-
116
- // Build headers via Headers API for prepareHeaders compatibility
144
+ // Build the request headers here (not via fetch's prepareHeaders seam):
145
+ // actions' hook is spec-aware and receives { spec }, which fetch's plain
146
+ // (headers) hook can't provide. Content-Type is set before prepareHeaders so
147
+ // the hook sees it, matching the prior behavior. The body itself is encoded
148
+ // by fetch (passed raw via opts.body).
117
149
  const headers = new Headers();
118
150
  if (spec.method !== "GET" && spec.body !== undefined) {
119
151
  headers.set("Content-Type", JSON_CT);
120
- init.body = JSON.stringify(spec.body);
121
152
  }
122
153
  if (ctx?.idempotencyKey !== undefined) {
123
154
  headers.set(IDEMPOTENCY_HEADER, ctx.idempotencyKey);
124
155
  }
125
- // Per-request headers from RequestSpec
156
+ // Per-request headers from RequestSpec.
126
157
  if (spec.headers !== undefined) {
127
158
  for (const [k, v] of Object.entries(spec.headers)) {
128
159
  headers.set(k, v);
129
160
  }
130
161
  }
131
- // Global prepareHeaders hook. Honor a returned Headers (RTK convention), falling
132
- // back to the mutated instance when the hook returns undefined.
162
+ // Global prepareHeaders hook. Honor a returned Headers (RTK convention),
163
+ // falling back to the mutated instance when the hook returns undefined. A
164
+ // throw/rejection here propagates before the request is dispatched, so fetch
165
+ // is never called — preserving the prior fail-before-fetch behavior.
133
166
  let effectiveHeaders = headers;
134
- if (cfg.prepareHeaders !== undefined) {
135
- const prepared = await cfg.prepareHeaders(headers, { spec });
167
+ if (apiPrepareHeaders !== undefined) {
168
+ const prepared = await apiPrepareHeaders(headers, { spec });
136
169
  if (prepared !== undefined) {
137
170
  effectiveHeaders = prepared;
138
171
  }
139
172
  }
140
- // Convert Headers to plain object for RequestInit
141
- const headerObj: Record<string, string> = {};
142
- effectiveHeaders.forEach((v, k) => {
143
- headerObj[k.toLowerCase()] = v;
144
- });
145
- if (Object.keys(headerObj).length > 0) {
146
- init.headers = headerObj;
147
- }
148
173
 
149
- // Credentials
150
- if (cfg.credentials !== undefined) {
151
- init.credentials = cfg.credentials;
174
+ // Delegate transport + envelope to the isolated fetch instance. baseUrl,
175
+ // credentials, and fetchFn live on the instance; fetch composes the caller
176
+ // signal with the timeout (withTimeout(signal, timeoutMs)) and applies the
177
+ // relative-path / origin-override contract when a baseUrl is configured.
178
+ const opts: RequestOptions<T> = {
179
+ signal,
180
+ timeoutMs: API_TIMEOUT_MS,
181
+ headers: effectiveHeaders,
182
+ };
183
+ if (spec.method !== "GET" && spec.body !== undefined) {
184
+ opts.body = spec.body;
152
185
  }
153
186
 
154
- init.signal = withTimeout(signal, API_TIMEOUT_MS);
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.
161
- // Resolve URL: prepend baseUrl if configured, normalizing double slashes at the join
162
- let url: string;
163
- if (cfg.baseUrl !== undefined) {
164
- const base = cfg.baseUrl.endsWith("/") ? cfg.baseUrl.slice(0, -1) : cfg.baseUrl;
165
- const path = spec.path.startsWith("/") ? spec.path : `/${spec.path}`;
166
- url = `${base}${path}`;
167
- } else {
168
- url = spec.path;
187
+ const result = await apiFetch.requestRaw<T>(spec.method, spec.path, opts);
188
+ if (result.ok) {
189
+ // fetch collapses a 204 and an empty-body 2xx to data === undefined. Warn
190
+ // on an unexpected empty body (a non-204, non-DELETE response), as before.
191
+ if (result.data === undefined && result.status !== 204 && spec.method !== "DELETE") {
192
+ console.warn(
193
+ `[actions] ${spec.method} ${spec.path} returned empty body callers expecting data will receive undefined`,
194
+ );
195
+ }
196
+ return result.data;
169
197
  }
198
+ throw actionErrorFromApiErr(result);
199
+ }
170
200
 
171
- // Use custom fetchFn or global fetch
172
- const fetchImpl = cfg.fetchFn ?? fetch;
173
-
174
- let r: Response;
175
- try {
176
- r = await fetchImpl(url, init);
177
- } catch (e) {
178
- throw classifyFetchError(e, signal);
179
- }
180
- if (!r.ok) {
181
- let serverError = "";
182
- let serverCode: string | undefined;
183
- try {
184
- const body: unknown = await r.json();
185
- if (hasErrorString(body)) {
186
- serverError = body.error;
187
- }
188
- if (typeof body === "object" && body !== null && "code" in body) {
189
- const code = (body as Record<"code", unknown>).code;
190
- if (typeof code === "string") {
191
- serverCode = code;
192
- }
193
- }
194
- } catch {
195
- // Body wasn't JSON — leave serverError empty.
201
+ /**
202
+ * Map a fetch {@link ApiErr} envelope onto an {@link ActionError} whose
203
+ * `status` / `code` match exactly what `executeRequest` produced before the
204
+ * `@cplieger/fetch` adoption, so `classifyFetchError` / `retryNetwork`
205
+ * consumers and the registry log observe identical errors.
206
+ *
207
+ * A `status` of 0 marks a client-side / transport failure whose reserved
208
+ * `code` is `network | timeout | cancelled | invalid`. Any other `status` is a
209
+ * real HTTP response: a non-2xx (whose `code`, if present, is a server-supplied
210
+ * string lifted from the error body) or a 2xx decode failure (`code: "decode"`).
211
+ */
212
+ function actionErrorFromApiErr(err: ApiErr): ActionError {
213
+ const { status, error, code } = err;
214
+ if (status === 0) {
215
+ if (code === "cancelled") {
216
+ return new ActionError("Request cancelled", { code: "cancelled" });
196
217
  }
197
- const opts: { status: number; code?: string } = { status: r.status };
198
- if (serverCode !== undefined) {
199
- opts.code = serverCode;
218
+ if (code === "timeout") {
219
+ return new ActionError("Request timed out", { status: 0, code: "timeout" });
200
220
  }
201
- throw new ActionError(serverError !== "" ? serverError : `HTTP ${String(r.status)}`, opts);
202
- }
203
- if (r.status === 204) {
204
- return undefined as T;
205
- }
206
- const text = await r.text();
207
- if (text === "") {
208
- if (spec.method !== "DELETE") {
209
- console.warn(
210
- `[actions] ${spec.method} ${spec.path} returned empty body — callers expecting data will receive undefined`,
211
- );
221
+ if (code === "invalid") {
222
+ // Client-side build failure (un-encodable body / bad header) that never
223
+ // reached the network: carry no HTTP status so retryNetwork treats it as
224
+ // non-retryable, matching the pre-adoption raw-throw behavior.
225
+ return new ActionError(error, { code: "invalid" });
212
226
  }
213
- return undefined as T;
227
+ return new ActionError(error, { status: 0, code: "network" });
214
228
  }
215
- try {
216
- return JSON.parse(text) as T;
217
- } catch (e) {
218
- throw new ActionError(`response not JSON: ${e instanceof Error ? e.message : String(e)}`, {
219
- status: r.status,
220
- cause: e,
221
- });
229
+ const opts: { status: number; code?: string } = { status };
230
+ if (code !== undefined) {
231
+ opts.code = code;
222
232
  }
233
+ return new ActionError(error, opts);
223
234
  }