@cplieger/actions 2.0.6 → 2.0.7
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 +11 -7
- package/jsr.json +1 -1
- package/package.json +4 -3
- package/src/api.ts +102 -91
package/README.md
CHANGED
|
@@ -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.
|
|
138
|
-
> `@cplieger/actions/
|
|
139
|
-
>
|
|
140
|
-
> `
|
|
141
|
-
> `
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/actions",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -13,6 +13,7 @@
|
|
|
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": {
|
|
@@ -22,8 +23,8 @@
|
|
|
22
23
|
"eslint": "10.6.0",
|
|
23
24
|
"fast-check": "4.8.0",
|
|
24
25
|
"happy-dom": "20.10.6",
|
|
25
|
-
"prettier": "3.9.
|
|
26
|
-
"typescript-eslint": "8.62.
|
|
26
|
+
"prettier": "3.9.4",
|
|
27
|
+
"typescript-eslint": "8.62.1",
|
|
27
28
|
"vitest": "4.1.9"
|
|
28
29
|
},
|
|
29
30
|
"description": "Declarative async UI-action framework for TypeScript",
|
package/src/api.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
// apiAction: factory for HTTP-backed actions.
|
|
2
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
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),
|
|
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 (
|
|
135
|
-
const prepared = await
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
opts.code = serverCode;
|
|
218
|
+
if (code === "timeout") {
|
|
219
|
+
return new ActionError("Request timed out", { status: 0, code: "timeout" });
|
|
200
220
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
227
|
+
return new ActionError(error, { status: 0, code: "network" });
|
|
214
228
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
}
|