@vahidkaargar/customized-api-client 0.2.2 → 0.2.4
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/LICENSE +21 -0
- package/README.md +274 -409
- package/dist/index.cjs +17 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +17 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
# `@vahidkaargar/customized-api-client`
|
|
2
2
|
|
|
3
|
-
TypeScript **Axios** client for **JSON:API v1.1** APIs
|
|
3
|
+
TypeScript **Axios** client for **JSON:API v1.1** APIs — Bearer auth, mandatory mutation idempotency, optimistic concurrency, retries, and **typed success/error results** so you rarely touch raw Axios responses.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| | |
|
|
6
|
+
|---|---|
|
|
7
|
+
| **Node** | ≥ 20 |
|
|
8
|
+
| **Modules** | ESM + CJS |
|
|
9
|
+
| **Changelog** | [CHANGELOG.md](./CHANGELOG.md) |
|
|
10
|
+
| **License** | MIT |
|
|
6
11
|
|
|
7
12
|
---
|
|
8
13
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
9. [Optimistic concurrency](#optimistic-concurrency)
|
|
20
|
-
10. [Retries](#retries)
|
|
21
|
-
11. [Pagination and query parameters](#pagination-and-query-parameters)
|
|
22
|
-
12. [Included resources](#included-resources)
|
|
23
|
-
13. [Async jobs (202) and polling](#async-jobs-202-and-polling)
|
|
24
|
-
14. [Bulk operations (207)](#bulk-operations-207)
|
|
25
|
-
15. [Response key transformation](#response-key-transformation)
|
|
26
|
-
16. [Guards and validation helpers](#guards-and-validation-helpers)
|
|
27
|
-
17. [Security and logging](#security-and-logging)
|
|
28
|
-
18. [Health checks](#health-checks)
|
|
29
|
-
19. [Typing your API](#typing-your-api)
|
|
30
|
-
20. [Advanced / low-level exports](#advanced--low-level-exports)
|
|
31
|
-
21. [API reference (exports)](#api-reference-exports)
|
|
32
|
-
22. [Development](#development-this-repository)
|
|
33
|
-
23. [Supply chain](#supply-chain)
|
|
34
|
-
24. [Publishing](#publishing-maintainers)
|
|
14
|
+
## What you get
|
|
15
|
+
|
|
16
|
+
- **JSON:API headers** — `Content-Type` / `Accept: application/vnd.api+json` on requests
|
|
17
|
+
- **Normalized responses** — narrow on `res.kind` (`jsonapi-success`, `no-content`, `accepted`, `multi-status`)
|
|
18
|
+
- **Errors** — `ApiClientError` with `status`, `primaryCode`, `errors[]`, safe `toJSON()` for logs
|
|
19
|
+
- **Idempotency** — `Idempotency-Key` on POST/PATCH/PUT/DELETE (ULID by default)
|
|
20
|
+
- **Concurrency** — `If-Match: "v=<n>"` via `patchWithVersion` or `ifMatchVersion`
|
|
21
|
+
- **Retries** — explicit policy (safe GET/HEAD vs mutations); honors `Retry-After`; optional mutation **5xx** retries
|
|
22
|
+
- **Two ergonomics** — throwing verbs **or** `safe*` methods returning `{ ok, value \| error }`
|
|
23
|
+
- **Cancellation** — optional `AbortSignal` per request
|
|
35
24
|
|
|
36
25
|
---
|
|
37
26
|
|
|
@@ -49,13 +38,10 @@ import { createApiClient } from '@vahidkaargar/customized-api-client';
|
|
|
49
38
|
|
|
50
39
|
## Quick start
|
|
51
40
|
|
|
41
|
+
**1. Create a client** (Mode B: `baseURL` already includes `/api/v1`):
|
|
42
|
+
|
|
52
43
|
```typescript
|
|
53
|
-
import {
|
|
54
|
-
createApiClient,
|
|
55
|
-
ApiClientError,
|
|
56
|
-
getNextPageUrl,
|
|
57
|
-
readResourceVersion,
|
|
58
|
-
} from '@vahidkaargar/customized-api-client';
|
|
44
|
+
import { createApiClient, ApiClientError } from '@vahidkaargar/customized-api-client';
|
|
59
45
|
|
|
60
46
|
const client = createApiClient({
|
|
61
47
|
baseURL: 'https://api.example.com/api/v1',
|
|
@@ -64,13 +50,15 @@ const client = createApiClient({
|
|
|
64
50
|
getToken: async () => sessionStorage.getItem('access_token'),
|
|
65
51
|
},
|
|
66
52
|
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**2. Call the API** (default: throws `ApiClientError` on HTTP errors):
|
|
67
56
|
|
|
57
|
+
```typescript
|
|
68
58
|
try {
|
|
69
59
|
const res = await client.get('/widgets');
|
|
70
60
|
if (res.kind === 'jsonapi-success') {
|
|
71
|
-
|
|
72
|
-
const next = getNextPageUrl(doc.links);
|
|
73
|
-
// ...
|
|
61
|
+
console.log(res.document.data);
|
|
74
62
|
}
|
|
75
63
|
} catch (e) {
|
|
76
64
|
if (e instanceof ApiClientError) {
|
|
@@ -80,535 +68,417 @@ try {
|
|
|
80
68
|
}
|
|
81
69
|
```
|
|
82
70
|
|
|
83
|
-
|
|
71
|
+
**3. Prefer no `try/catch` for expected errors?** Use `safe*` — see [Error handling](#error-handling).
|
|
84
72
|
|
|
85
|
-
|
|
73
|
+
---
|
|
86
74
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
| Option | Type | Default | Description |
|
|
90
|
-
|--------|------|---------|-------------|
|
|
91
|
-
| `baseURL` | `string` | **required** | API origin; see [Base URL modes](#base-url-modes). |
|
|
92
|
-
| `baseUrlMode` | `'modeB' \| 'modeA'` | `'modeB'` | How relative paths are joined with `baseURL`. |
|
|
93
|
-
| `auth` | `AuthConfig` | — | Bearer token or partner secret provider. |
|
|
94
|
-
| `getAcceptLanguage` | `() => string \| null \| …` | — | Sets `Accept-Language` when returned value is non-empty. |
|
|
95
|
-
| `defaultHeaders` | `Record<string, string>` | — | Merged into every request (after JSON:API defaults). |
|
|
96
|
-
| `timeout` | `number` | `30000` | Axios timeout in ms. |
|
|
97
|
-
| `retry` | `RetryOptions` | see [Retries](#retries) | Bounded retry with backoff. |
|
|
98
|
-
| `generateIdempotencyKey` | `() => string` | ULID | Custom idempotency key factory for mutations. |
|
|
99
|
-
| `onIdempotencyReplay` | `(ctx) => void` | — | Called when response has `Idempotent-Replayed: true`. |
|
|
100
|
-
| `onUnauthorized` | `(error) => void \| Promise<void>` | — | Called when a response normalizes to **401**. |
|
|
101
|
-
| `onDeprecated` | `(info) => void` | — | Called when deprecation/sunset headers are present. |
|
|
102
|
-
| `transformResponseKeys` | `'none' \| 'camelCase-attributes-meta'` | `'none'` | Optional camelCase on `attributes` / `meta` only. |
|
|
103
|
-
| `maxBodyLogLength` | `number` | — | Documented cap for logging; use `truncateForLog` in app code. |
|
|
75
|
+
## Guide for application developers
|
|
104
76
|
|
|
105
|
-
###
|
|
77
|
+
### Pick a base URL mode
|
|
106
78
|
|
|
107
|
-
|
|
79
|
+
| Mode | `baseURL` example | Path you pass | Result |
|
|
80
|
+
|------|-------------------|---------------|--------|
|
|
81
|
+
| **B** (default) | `https://api.example.com/api/v1` | `/widgets` | `…/api/v1/widgets` |
|
|
82
|
+
| **A** | `https://api.example.com` + `baseUrlMode: 'modeA'` | `/widgets` | `…/api/v1/widgets` |
|
|
108
83
|
|
|
109
|
-
|
|
110
|
-
// User session token
|
|
111
|
-
auth: { type: 'bearer', getToken: () => getAccessToken() }
|
|
84
|
+
Duplicate `/api/v1` in path + base is stripped automatically in Mode B.
|
|
112
85
|
|
|
113
|
-
|
|
114
|
-
auth: { type: 'partner-bearer', getSecret: () => process.env.PARTNER_SECRET }
|
|
115
|
-
```
|
|
86
|
+
**Absolute URLs:** `getByUrl(links.next)` or `request({ url: 'https://…' })` — still get auth, JSON:API headers, and retries.
|
|
116
87
|
|
|
117
|
-
|
|
88
|
+
### Pick error handling
|
|
118
89
|
|
|
119
|
-
|
|
90
|
+
| Style | When to use | Example |
|
|
91
|
+
|-------|-------------|---------|
|
|
92
|
+
| **Throwing** (`get`, `post`, …) | Happy path + rare errors; familiar Axios flow | `try { await client.get(…) } catch (e) { … }` |
|
|
93
|
+
| **Safe** (`safeGet`, `safePost`, …) | Branch on status without `try/catch` | `const r = await client.safeGet(…); if (!r.ok) …` |
|
|
120
94
|
|
|
121
|
-
|
|
95
|
+
Every throwing method has a `safe*` twin, including `safeGetByUrl` and `safePatchWithVersion`.
|
|
122
96
|
|
|
123
|
-
|
|
97
|
+
Non-`ApiClientError` failures (bugs, abort, network quirks) still **throw** from `safe*`.
|
|
124
98
|
|
|
125
|
-
|
|
99
|
+
### Per-request options
|
|
126
100
|
|
|
127
101
|
```typescript
|
|
128
|
-
|
|
129
|
-
await client.get('/admin/teams'); // → https://api.example.com/api/v1/admin/teams
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
If a path accidentally repeats `/api/v1` while `baseURL` already ends with `/api/v1`, the client strips the duplicate segment.
|
|
102
|
+
const controller = new AbortController();
|
|
133
103
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
createApiClient({
|
|
140
|
-
baseURL: 'https://api.example.com',
|
|
141
|
-
baseUrlMode: 'modeA',
|
|
104
|
+
await client.patch('/widgets/42', body, {
|
|
105
|
+
idempotencyKey: 'stable-key-for-this-action', // optional; max 64 chars
|
|
106
|
+
ifMatchVersion: 7, // sets If-Match: "v=7"
|
|
107
|
+
signal: controller.signal, // cancel with controller.abort()
|
|
142
108
|
});
|
|
143
|
-
await client.get('/teams'); // → https://api.example.com/api/v1/teams
|
|
144
109
|
```
|
|
145
110
|
|
|
146
|
-
### Absolute URLs
|
|
147
|
-
|
|
148
|
-
`getByUrl(fullUrl)` and `request({ url: 'https://…' })` use the URL as-is (still through interceptors: auth, JSON:API headers, retries).
|
|
149
|
-
|
|
150
111
|
---
|
|
151
112
|
|
|
152
|
-
##
|
|
113
|
+
## Recipes
|
|
153
114
|
|
|
154
|
-
|
|
115
|
+
Copy-paste patterns for everyday work.
|
|
155
116
|
|
|
156
|
-
|
|
157
|
-
|--------|------|--------|
|
|
158
|
-
| `get(path, opts?)` | GET | |
|
|
159
|
-
| `head(path, opts?)` | HEAD | Often `no-content` (204). |
|
|
160
|
-
| `post(path, data?, opts?)` | POST | Sends idempotency key. |
|
|
161
|
-
| `patch(path, data?, opts?)` | PATCH | Sends idempotency key. |
|
|
162
|
-
| `put(path, data?, opts?)` | PUT | Sends idempotency key. |
|
|
163
|
-
| `delete(path, opts?)` | DELETE | Sends idempotency key. |
|
|
164
|
-
| `patchWithVersion(path, data, version, opts?)` | PATCH | Sets `If-Match: "v=<version>"`. |
|
|
165
|
-
| `getByUrl(fullUrl, opts?)` | GET | Follow `links.next` or external URLs. |
|
|
166
|
-
| `request(axConfig, opts?)` | any | Escape hatch; merges `opts` with Axios config. |
|
|
167
|
-
|
|
168
|
-
Per-call options (`RequestCallOptions`):
|
|
117
|
+
### List resources and follow `links.next`
|
|
169
118
|
|
|
170
119
|
```typescript
|
|
171
|
-
|
|
172
|
-
idempotencyKey: 'my-stable-key', // max 64 chars
|
|
173
|
-
ifMatchVersion: 3,
|
|
174
|
-
});
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### Safe variants (no throw on `ApiClientError`)
|
|
120
|
+
import { getNextPageUrl } from '@vahidkaargar/customized-api-client';
|
|
178
121
|
|
|
179
|
-
|
|
122
|
+
let res = await client.get('/widgets');
|
|
123
|
+
while (res.kind === 'jsonapi-success') {
|
|
124
|
+
// use res.document.data …
|
|
125
|
+
const next = getNextPageUrl(res.document.links);
|
|
126
|
+
if (!next) break;
|
|
127
|
+
res = await client.getByUrl(next);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
180
130
|
|
|
181
|
-
|
|
131
|
+
### Create (POST) with a stable idempotency key
|
|
182
132
|
|
|
183
133
|
```typescript
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
throw r.error;
|
|
188
|
-
}
|
|
189
|
-
const doc = r.value.kind === 'jsonapi-success' ? r.value.document : undefined;
|
|
134
|
+
await client.post('/widgets', payload, {
|
|
135
|
+
idempotencyKey: `create-widget-${userId}-${draftId}`,
|
|
136
|
+
});
|
|
190
137
|
```
|
|
191
138
|
|
|
192
|
-
|
|
139
|
+
### Update with optimistic locking
|
|
193
140
|
|
|
194
|
-
|
|
141
|
+
```typescript
|
|
142
|
+
import { readResourceVersion } from '@vahidkaargar/customized-api-client';
|
|
195
143
|
|
|
196
|
-
|
|
144
|
+
const current = await client.get('/widgets/42');
|
|
145
|
+
if (current.kind !== 'jsonapi-success' || Array.isArray(current.document.data)) {
|
|
146
|
+
throw new Error('expected single resource');
|
|
147
|
+
}
|
|
148
|
+
const version = readResourceVersion(current.document.data, current.headers.etag);
|
|
197
149
|
|
|
198
|
-
|
|
150
|
+
await client.patchWithVersion('/widgets/42', payload, version);
|
|
151
|
+
// or: client.patch('/widgets/42', payload, { ifMatchVersion: version })
|
|
152
|
+
```
|
|
199
153
|
|
|
200
|
-
###
|
|
154
|
+
### Handle validation errors (422)
|
|
201
155
|
|
|
202
156
|
```typescript
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
157
|
+
import {
|
|
158
|
+
groupValidationErrorsByPointer,
|
|
159
|
+
isValidationError,
|
|
160
|
+
} from '@vahidkaargar/customized-api-client';
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await client.post('/widgets', payload);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (e instanceof ApiClientError && isValidationError(e)) {
|
|
166
|
+
const byField = groupValidationErrorsByPointer(e.errors);
|
|
167
|
+
// { '/data/attributes/name': ['too short'], … }
|
|
168
|
+
}
|
|
209
169
|
}
|
|
210
170
|
```
|
|
211
171
|
|
|
212
|
-
###
|
|
172
|
+
### Safe GET when 404 is normal
|
|
213
173
|
|
|
214
174
|
```typescript
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
175
|
+
const r = await client.safeGet('/widgets/unknown-id');
|
|
176
|
+
if (!r.ok && r.error.status === 404) return null;
|
|
177
|
+
if (!r.ok) throw r.error;
|
|
178
|
+
return r.value.kind === 'jsonapi-success' ? r.value.document : undefined;
|
|
219
179
|
```
|
|
220
180
|
|
|
221
|
-
###
|
|
181
|
+
### Cancel an in-flight request
|
|
222
182
|
|
|
223
183
|
```typescript
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const promise = client.get('/slow-report', { signal: controller.signal });
|
|
186
|
+
setTimeout(() => controller.abort(), 5_000);
|
|
187
|
+
await promise; // throws when aborted
|
|
228
188
|
```
|
|
229
189
|
|
|
230
|
-
###
|
|
190
|
+
### Poll an async job (202)
|
|
231
191
|
|
|
232
192
|
```typescript
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
193
|
+
import { pollAsyncResult } from '@vahidkaargar/customized-api-client';
|
|
194
|
+
|
|
195
|
+
const accepted = await client.post('/jobs', jobPayload);
|
|
196
|
+
if (accepted.kind !== 'accepted') throw new Error('expected 202');
|
|
197
|
+
|
|
198
|
+
const done = await pollAsyncResult(client, accepted, {
|
|
199
|
+
maxAttempts: 10,
|
|
200
|
+
delayMs: 500,
|
|
201
|
+
});
|
|
239
202
|
```
|
|
240
203
|
|
|
241
|
-
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Configuration
|
|
207
|
+
|
|
208
|
+
Client-level options for `createApiClient({ … })`:
|
|
209
|
+
|
|
210
|
+
| Option | Default | Description |
|
|
211
|
+
|--------|---------|-------------|
|
|
212
|
+
| `baseURL` | **required** | API origin — [base URL modes](#pick-a-base-url-mode) |
|
|
213
|
+
| `baseUrlMode` | `'modeB'` | `'modeB'` \| `'modeA'` |
|
|
214
|
+
| `auth` | — | `{ type: 'bearer', getToken }` or `{ type: 'partner-bearer', getSecret }` |
|
|
215
|
+
| `timeout` | `30000` | Axios timeout (ms) |
|
|
216
|
+
| `defaultHeaders` | — | Merged into every request |
|
|
217
|
+
| `retry` | see [Retries](#retries) | `maxAttempts`, backoff, jitter |
|
|
218
|
+
| `generateIdempotencyKey` | ULID | Factory for mutation keys |
|
|
219
|
+
| `getAcceptLanguage` | — | Sets `Accept-Language` when non-empty |
|
|
220
|
+
| `onIdempotencyReplay` | — | Fired when `Idempotent-Replayed: true` |
|
|
221
|
+
| `onUnauthorized` | — | Fired on normalized **401** |
|
|
222
|
+
| `onDeprecated` | — | Deprecation / sunset headers |
|
|
223
|
+
| `transformResponseKeys` | `'none'` | `'camelCase-attributes-meta'` on response only |
|
|
224
|
+
| `maxBodyLogLength` | — | Hint for app logging; use `truncateForLog` |
|
|
225
|
+
|
|
226
|
+
### Authentication
|
|
227
|
+
|
|
228
|
+
User and partner credentials both use **`Authorization: Bearer …`**.
|
|
242
229
|
|
|
243
230
|
```typescript
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
res.headers.idempotentReplayed: boolean;
|
|
247
|
-
res.headers.retryAfterSeconds?: number;
|
|
231
|
+
auth: { type: 'bearer', getToken: () => getAccessToken() }
|
|
232
|
+
auth: { type: 'partner-bearer', getSecret: () => process.env.PARTNER_SECRET }
|
|
248
233
|
```
|
|
249
234
|
|
|
235
|
+
If `getToken` / `getSecret` returns `null` or `undefined`, no `Authorization` header is sent.
|
|
236
|
+
|
|
250
237
|
---
|
|
251
238
|
|
|
252
|
-
##
|
|
239
|
+
## Making requests
|
|
253
240
|
|
|
254
|
-
###
|
|
241
|
+
### Throwing methods
|
|
255
242
|
|
|
256
|
-
|
|
243
|
+
| Method | HTTP | Notes |
|
|
244
|
+
|--------|------|--------|
|
|
245
|
+
| `get` / `head` | GET / HEAD | No idempotency key |
|
|
246
|
+
| `post` / `patch` / `put` / `delete` | … | Sends `Idempotency-Key` |
|
|
247
|
+
| `patchWithVersion(path, data, version)` | PATCH | Sets `If-Match` |
|
|
248
|
+
| `getByUrl(fullUrl)` | GET | Absolute URL (e.g. `links.next`) |
|
|
249
|
+
| `request(axConfig, opts?)` | any | Low-level escape hatch |
|
|
257
250
|
|
|
258
|
-
|
|
259
|
-
import { ApiClientError, isApiClientError } from '@vahidkaargar/customized-api-client';
|
|
251
|
+
### Safe methods
|
|
260
252
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
e.requestMethod; // e.g. 'PATCH'
|
|
270
|
-
e.responseHeaders; // redacted in toJSON()
|
|
271
|
-
console.log(e.toJSON()); // safe for logs (secrets redacted)
|
|
272
|
-
}
|
|
253
|
+
`safeGet`, `safeHead`, `safePost`, `safePatch`, `safePut`, `safeDelete`, `safeRequest`, `safeGetByUrl`, `safePatchWithVersion`
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
const r = await client.safePatchWithVersion('/widgets/1', body, 3);
|
|
257
|
+
if (r.ok) {
|
|
258
|
+
console.log(r.value);
|
|
259
|
+
} else {
|
|
260
|
+
console.log(r.error.status, r.error.primaryCode);
|
|
273
261
|
}
|
|
274
262
|
```
|
|
275
263
|
|
|
276
|
-
|
|
264
|
+
---
|
|
277
265
|
|
|
278
|
-
|
|
266
|
+
## Success results (`ClientSuccess`)
|
|
279
267
|
|
|
280
|
-
|
|
281
|
-
import {
|
|
282
|
-
groupValidationErrorsByPointer,
|
|
283
|
-
isValidationError,
|
|
284
|
-
} from '@vahidkaargar/customized-api-client';
|
|
268
|
+
Narrow on `kind`:
|
|
285
269
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
270
|
+
| `kind` | Status | Main fields |
|
|
271
|
+
|--------|--------|-------------|
|
|
272
|
+
| `jsonapi-success` | 200, 201 | `document`, `headers` |
|
|
273
|
+
| `no-content` | 204 | `headers` |
|
|
274
|
+
| `accepted` | 202 | `location`, `rawBody?` |
|
|
275
|
+
| `multi-status` | 207 | `items[]` with `httpStatus`, `body` |
|
|
276
|
+
|
|
277
|
+
All success kinds expose `headers.etag`, `headers.contentLanguage`, `headers.idempotentReplayed`, and optional `headers.retryAfterSeconds`.
|
|
291
278
|
|
|
292
279
|
---
|
|
293
280
|
|
|
294
|
-
##
|
|
281
|
+
## Error handling
|
|
295
282
|
|
|
296
|
-
|
|
297
|
-
- Override per call: `opts.idempotencyKey` (non-empty, max **64** characters).
|
|
298
|
-
- Replace generator: `generateIdempotencyKey: () => myKey()`.
|
|
299
|
-
- On retry, the **same key and body** are reused.
|
|
300
|
-
- When the server replays a prior result, response header **`Idempotent-Replayed: true`** sets `headers.idempotentReplayed` and invokes **`onIdempotencyReplay`**:
|
|
283
|
+
### `ApiClientError` (default on throwing methods)
|
|
301
284
|
|
|
302
|
-
|
|
303
|
-
onIdempotencyReplay: ({ url, method }) => {
|
|
304
|
-
metrics.increment('idempotency_replay');
|
|
305
|
-
},
|
|
306
|
-
```
|
|
285
|
+
HTTP **≥ 400** and malformed JSON:API error bodies throw **`ApiClientError`**.
|
|
307
286
|
|
|
308
|
-
|
|
287
|
+
| Field | Meaning |
|
|
288
|
+
|-------|---------|
|
|
289
|
+
| `status` | HTTP status |
|
|
290
|
+
| `primaryCode` | First `errors[].code` |
|
|
291
|
+
| `errors` | `JsonApiErrorObject[]` |
|
|
292
|
+
| `retryAfterSeconds` | From `Retry-After` when parseable |
|
|
293
|
+
| `requestMethod` | e.g. `'PATCH'` |
|
|
294
|
+
| `toJSON()` | Log-safe (secrets redacted) |
|
|
295
|
+
|
|
296
|
+
Synthetic codes when the body is missing or invalid: `EMPTY_ERROR_BODY`, `INVALID_JSON`, `MISSING_ERRORS_ARRAY`.
|
|
297
|
+
|
|
298
|
+
### Guards
|
|
299
|
+
|
|
300
|
+
| Helper | True when |
|
|
301
|
+
|--------|-----------|
|
|
302
|
+
| `isAuthenticationError` | 401 |
|
|
303
|
+
| `isForbiddenError` | 403 |
|
|
304
|
+
| `isValidationError` | 422 |
|
|
305
|
+
| `isPreconditionRequiredError` | 428 |
|
|
306
|
+
| `isPreconditionFailedError` | 412 |
|
|
307
|
+
| `isConflictError` | 409 |
|
|
308
|
+
| `isPayloadTooLargeError` | 413 |
|
|
309
|
+
| `isRetryablePerPolicy` | Would retry per client policy (UI hints); pass `{ retryMutationsOnServerError: true }` to match clients that opt into mutation **5xx** retries |
|
|
309
310
|
|
|
310
311
|
---
|
|
311
312
|
|
|
312
|
-
##
|
|
313
|
+
## Idempotency
|
|
313
314
|
|
|
314
|
-
|
|
315
|
+
- **POST, PATCH, PUT, DELETE** send **`Idempotency-Key`** (ULID per request by default).
|
|
316
|
+
- Retries reuse the **same key and body** within one client call (`dispatchWithRetry`).
|
|
317
|
+
- For **separate** `post`/`patch`/… invocations, pass the same `idempotencyKey` yourself if they represent the same user intent.
|
|
318
|
+
- Server replay → header `Idempotent-Replayed: true` → `headers.idempotentReplayed` + optional `onIdempotencyReplay`.
|
|
319
|
+
- **GET / HEAD** never send idempotency keys.
|
|
315
320
|
|
|
316
|
-
|
|
317
|
-
// Convenience helper
|
|
318
|
-
await client.patchWithVersion('/widgets/42', body, 7);
|
|
321
|
+
---
|
|
319
322
|
|
|
320
|
-
|
|
321
|
-
await client.patch('/widgets/42', body, { ifMatchVersion: 7 });
|
|
322
|
-
```
|
|
323
|
+
## Optimistic concurrency
|
|
323
324
|
|
|
324
|
-
|
|
325
|
+
Send **`If-Match: "v=<n>"`** when the resource is versioned:
|
|
325
326
|
|
|
326
327
|
```typescript
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const res = await client.get('/widgets/42');
|
|
330
|
-
if (res.kind !== 'jsonapi-success' || !res.document.data || Array.isArray(res.document.data)) {
|
|
331
|
-
throw new Error('expected single resource');
|
|
332
|
-
}
|
|
333
|
-
const resource = res.document.data;
|
|
334
|
-
const version = readResourceVersion(resource, res.headers.etag);
|
|
335
|
-
// Prefer meta.version (number or numeric string), else ETag v=n
|
|
328
|
+
await client.patchWithVersion('/widgets/42', body, version);
|
|
336
329
|
```
|
|
337
330
|
|
|
338
|
-
|
|
331
|
+
Read version with `readResourceVersion(resource, etag)` — prefers `meta.version`, else ETag `v=n`.
|
|
332
|
+
|
|
333
|
+
**412** / **428** are not auto-retried.
|
|
339
334
|
|
|
340
335
|
---
|
|
341
336
|
|
|
342
337
|
## Retries
|
|
343
338
|
|
|
344
|
-
Default `retry`:
|
|
345
|
-
|
|
346
339
|
| Field | Default |
|
|
347
340
|
|-------|---------|
|
|
348
341
|
| `maxAttempts` | `4` |
|
|
349
342
|
| `baseDelayMs` | `200` |
|
|
350
343
|
| `maxDelayMs` | `10000` |
|
|
351
344
|
| `jitterRatio` | `0.2` |
|
|
352
|
-
|
|
353
|
-
Honors **`Retry-After`** when present (seconds or HTTP-date).
|
|
354
|
-
|
|
355
|
-
### Policy summary
|
|
345
|
+
| `retryMutationsOnServerError` | `false` |
|
|
356
346
|
|
|
357
347
|
| Situation | Retried? |
|
|
358
348
|
|-----------|----------|
|
|
359
|
-
| Network error (no response) | Yes
|
|
349
|
+
| Network error (no response) | Yes |
|
|
360
350
|
| GET/HEAD **408, 429, 5xx** | Yes |
|
|
361
|
-
| GET/HEAD **401, 403, 412, 428
|
|
362
|
-
|
|
|
363
|
-
|
|
|
351
|
+
| GET/HEAD **401, 403, 412, 428**, validation 4xx | No |
|
|
352
|
+
| Mutations **5xx / 429** | No (set `retry: { retryMutationsOnServerError: true }` to retry **5xx** only; **429** stays off) |
|
|
353
|
+
| Mutation **409** `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Yes |
|
|
364
354
|
| **409** `IDEMPOTENCY_KEY_REUSED` | No |
|
|
365
|
-
| **401 / 403 / 412 / 428 / 422** etc. | No |
|
|
366
355
|
|
|
367
|
-
|
|
356
|
+
When `retryMutationsOnServerError` is **true**, POST/PUT/PATCH/DELETE responses with status **500–599** use the same backoff and `Retry-After` handling as reads, with the **same** request config (so the same `Idempotency-Key` and body). Use this when your server does **not** persist a replay body for **5xx** and allows the handler to run again for the same key.
|
|
368
357
|
|
|
369
|
-
|
|
358
|
+
Disable: `retry: { maxAttempts: 1 }`. Inspect logic: `retryAllowed({ … })` (include `retryMutationsOnServerError` when mirroring client config). For thrown errors: `isRetryablePerPolicy(err, { retryMutationsOnServerError: true })`.
|
|
370
359
|
|
|
371
360
|
---
|
|
372
361
|
|
|
373
|
-
##
|
|
362
|
+
## More helpers
|
|
374
363
|
|
|
375
|
-
###
|
|
376
|
-
|
|
377
|
-
```typescript
|
|
378
|
-
import { getNextPageUrl, parsePaginationKind } from '@vahidkaargar/customized-api-client';
|
|
379
|
-
|
|
380
|
-
let res = await client.get('/widgets');
|
|
381
|
-
while (res.kind === 'jsonapi-success') {
|
|
382
|
-
const kind = parsePaginationKind(res.document.meta, res.document.links);
|
|
383
|
-
// kind: 'offset' | 'cursor' | 'unknown'
|
|
384
|
-
|
|
385
|
-
const next = getNextPageUrl(res.document.links);
|
|
386
|
-
if (!next) break;
|
|
387
|
-
res = await client.getByUrl(next);
|
|
388
|
-
}
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
`parsePaginationKind` understands:
|
|
392
|
-
|
|
393
|
-
- JSON:API `page[number]`, `page[cursor]`, `page[size]` in `links.next`
|
|
394
|
-
- Legacy `page` / `per_page` in URLs
|
|
395
|
-
- `meta` fields: `current_page`, `last_page`, `has_more`, `next_cursor`, etc.
|
|
396
|
-
|
|
397
|
-
### Building query strings
|
|
364
|
+
### Pagination and query building
|
|
398
365
|
|
|
399
366
|
```typescript
|
|
400
367
|
import {
|
|
401
368
|
buildJsonApiQuery,
|
|
402
369
|
buildOffsetPageParams,
|
|
403
370
|
buildCursorPageParams,
|
|
404
|
-
|
|
371
|
+
parsePaginationKind,
|
|
405
372
|
} from '@vahidkaargar/customized-api-client';
|
|
406
373
|
|
|
407
374
|
const params = {
|
|
408
375
|
...buildJsonApiQuery({
|
|
409
|
-
filter: { status: 'active'
|
|
410
|
-
sort: ['-created_at'
|
|
411
|
-
|
|
412
|
-
include: ['owner', 'tags'],
|
|
376
|
+
filter: { status: 'active' },
|
|
377
|
+
sort: ['-created_at'],
|
|
378
|
+
include: ['owner'],
|
|
413
379
|
}),
|
|
414
380
|
...buildOffsetPageParams({ number: 2, size: 50 }),
|
|
415
381
|
};
|
|
416
|
-
// page[size] is capped at DEFAULT_PAGE_SIZE_CAP (100)
|
|
417
382
|
|
|
418
|
-
await client.request({
|
|
419
|
-
method: 'GET',
|
|
420
|
-
url: '/widgets',
|
|
421
|
-
params,
|
|
422
|
-
});
|
|
383
|
+
await client.request({ method: 'GET', url: '/widgets', params });
|
|
423
384
|
```
|
|
424
385
|
|
|
425
|
-
|
|
426
|
-
const cursorParams = buildCursorPageParams({ cursor: 'abc123', size: 25 });
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
---
|
|
430
|
-
|
|
431
|
-
## Included resources
|
|
386
|
+
### Included resources
|
|
432
387
|
|
|
433
388
|
```typescript
|
|
434
389
|
import { indexIncluded, resolveIncluded } from '@vahidkaargar/customized-api-client';
|
|
435
390
|
|
|
436
|
-
const res = await client.get('/widgets/1?include=owner');
|
|
437
|
-
if (res.kind !== 'jsonapi-success') return;
|
|
438
|
-
|
|
439
391
|
const idx = indexIncluded(res.document.included);
|
|
440
|
-
const
|
|
441
|
-
const owner = resolveIncluded(ownerRef, idx);
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
---
|
|
445
|
-
|
|
446
|
-
## Async jobs (202) and polling
|
|
447
|
-
|
|
448
|
-
```typescript
|
|
449
|
-
import { pollAsyncResult } from '@vahidkaargar/customized-api-client';
|
|
450
|
-
|
|
451
|
-
const accepted = await client.post('/jobs', { data: { type: 'jobs', attributes: { … } } });
|
|
452
|
-
if (accepted.kind !== 'accepted') throw new Error('expected 202');
|
|
453
|
-
|
|
454
|
-
const done = await pollAsyncResult(client, accepted, {
|
|
455
|
-
maxAttempts: 10,
|
|
456
|
-
delayMs: 500,
|
|
457
|
-
});
|
|
458
|
-
// Polls GET on location until non-202 or max attempts (then throws)
|
|
392
|
+
const owner = resolveIncluded({ type: 'users', id: '9' }, idx);
|
|
459
393
|
```
|
|
460
394
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
---
|
|
464
|
-
|
|
465
|
-
## Bulk operations (207)
|
|
395
|
+
### Bulk 207
|
|
466
396
|
|
|
467
397
|
```typescript
|
|
468
398
|
const res = await client.post('/bulk/widgets', bulkPayload);
|
|
469
399
|
if (res.kind === 'multi-status') {
|
|
470
400
|
for (const item of res.items) {
|
|
471
|
-
if (item.httpStatus >= 400) {
|
|
472
|
-
// item.body may be a JSON:API error document
|
|
473
|
-
}
|
|
401
|
+
if (item.httpStatus >= 400) { /* item.body may be errors */ }
|
|
474
402
|
}
|
|
475
403
|
}
|
|
476
404
|
```
|
|
477
405
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
## Response key transformation
|
|
481
|
-
|
|
482
|
-
Wire format uses **snake_case** in JSON:API `attributes` / `meta`. Opt in to shallow **camelCase** on responses only:
|
|
406
|
+
### Response camelCase (opt-in)
|
|
483
407
|
|
|
484
408
|
```typescript
|
|
485
|
-
|
|
486
|
-
baseURL: '
|
|
409
|
+
createApiClient({
|
|
410
|
+
baseURL: '…',
|
|
487
411
|
transformResponseKeys: 'camelCase-attributes-meta',
|
|
488
412
|
});
|
|
489
|
-
|
|
490
|
-
const res = await client.get('/widgets/1');
|
|
491
|
-
if (res.kind === 'jsonapi-success' && !Array.isArray(res.document.data) && res.document.data) {
|
|
492
|
-
const attrs = res.document.data.attributes as { displayName?: string };
|
|
493
|
-
}
|
|
413
|
+
// Request bodies are NOT transformed — send snake_case on the wire.
|
|
494
414
|
```
|
|
495
415
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
---
|
|
499
|
-
|
|
500
|
-
## Guards and validation helpers
|
|
501
|
-
|
|
502
|
-
| Function | True when |
|
|
503
|
-
|----------|-----------|
|
|
504
|
-
| `isAuthenticationError` | `ApiClientError` status **401** |
|
|
505
|
-
| `isForbiddenError` | **403** |
|
|
506
|
-
| `isValidationError` | **422** |
|
|
507
|
-
| `isPreconditionRequiredError` | **428** |
|
|
508
|
-
| `isPreconditionFailedError` | **412** |
|
|
509
|
-
| `isConflictError` | **409** |
|
|
510
|
-
| `isPayloadTooLargeError` | **413** |
|
|
511
|
-
| `isRetryablePerPolicy` | Would retry per client policy (usually for UI hints, not for manual retry of failed calls) |
|
|
416
|
+
### Security and logging
|
|
512
417
|
|
|
513
|
-
|
|
418
|
+
- Tokens only from your `getToken` / `getSecret` callbacks.
|
|
419
|
+
- `redactHeaderRecord`, `truncateForLog`, `ApiClientError.toJSON()` redact secrets.
|
|
420
|
+
- Non-HTTPS `baseURL` (except localhost) logs a one-time warning.
|
|
514
421
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
- Tokens come only from your **`getToken` / `getSecret`** callbacks; the client does not store credentials.
|
|
518
|
-
- **`ApiClientError.toJSON()`** and **`redactHeaderRecord()`** redact `Authorization` and `Idempotency-Key`.
|
|
519
|
-
- **`truncateForLog(value, maxLen)`** safely stringifies values for logs (truncates, handles circular refs).
|
|
520
|
-
- Non-HTTPS `baseURL` outside **localhost** logs a **one-time** console warning.
|
|
521
|
-
|
|
522
|
-
```typescript
|
|
523
|
-
import { truncateForLog, redactHeaderRecord } from '@vahidkaargar/customized-api-client';
|
|
524
|
-
|
|
525
|
-
logger.info(truncateForLog(responseBody, 2_000));
|
|
526
|
-
```
|
|
527
|
-
|
|
528
|
-
---
|
|
529
|
-
|
|
530
|
-
## Health checks
|
|
422
|
+
### Health check
|
|
531
423
|
|
|
532
424
|
```typescript
|
|
533
425
|
import { createHealthCheck } from '@vahidkaargar/customized-api-client';
|
|
534
426
|
|
|
535
|
-
const ping = createHealthCheck(client);
|
|
536
|
-
const ok = await ping(); // GET /health/live
|
|
427
|
+
const ping = createHealthCheck(client);
|
|
428
|
+
const ok = await ping(); // GET /health/live
|
|
537
429
|
```
|
|
538
430
|
|
|
539
431
|
---
|
|
540
432
|
|
|
541
433
|
## Typing your API
|
|
542
434
|
|
|
543
|
-
This package
|
|
544
|
-
|
|
545
|
-
If you want OpenAPI-driven types:
|
|
435
|
+
This package ships **generic JSON:API types**, not endpoint-specific OpenAPI types.
|
|
546
436
|
|
|
547
|
-
1. Keep
|
|
548
|
-
2.
|
|
549
|
-
3.
|
|
437
|
+
1. Keep OpenAPI in your backend or `@myorg/api-types`.
|
|
438
|
+
2. Codegen there (e.g. `openapi-typescript`).
|
|
439
|
+
3. Use generics at call sites:
|
|
550
440
|
|
|
551
441
|
```typescript
|
|
552
|
-
import { createApiClient } from '@vahidkaargar/customized-api-client';
|
|
553
442
|
import type { operations } from '@myorg/api-types';
|
|
554
443
|
|
|
555
|
-
const client = createApiClient({ baseURL: 'https://api.example.com/api/v1', getToken: async () => token });
|
|
556
|
-
|
|
557
444
|
type MeResponse = operations['getMe']['responses'][200]['content']['application/vnd.api+json'];
|
|
558
445
|
const me = await client.get<MeResponse>('/me');
|
|
559
446
|
```
|
|
560
447
|
|
|
561
|
-
|
|
448
|
+
String paths and manual types work without OpenAPI.
|
|
562
449
|
|
|
563
450
|
---
|
|
564
451
|
|
|
565
|
-
## Advanced
|
|
452
|
+
## Advanced exports
|
|
566
453
|
|
|
567
|
-
For custom
|
|
454
|
+
For custom wrappers, tests, or pipelines:
|
|
568
455
|
|
|
569
|
-
|
|
570
|
-
|--------|---------|
|
|
571
|
-
| `normalizeAxiosResponse` | Map raw Axios response → `ClientSuccess` / throw |
|
|
572
|
-
| `parseJsonApiDocument` / `parseJsonApiErrorBody` | Parse success/error payloads |
|
|
573
|
-
| `dispatchWithRetry` | Retry wrapper around an `AxiosInstance` |
|
|
574
|
-
| `applyJsonApiHeaders` | Content-Type / Accept for JSON:API |
|
|
575
|
-
| `resolveResourcePath` | Mode A/B path resolution |
|
|
576
|
-
| `flattenAxiosHeaders` / `getHeader` | Header normalization |
|
|
577
|
-
| `parseMultiStatusBody` / `resolveAcceptedLocation` | 207 / 202 helpers |
|
|
578
|
-
| `parseRetryAfterSeconds` | Retry-After parsing |
|
|
579
|
-
| `assertValidIdempotencyKey` / `defaultIdempotencyKey` | Idempotency utilities |
|
|
456
|
+
`normalizeAxiosResponse`, `parseJsonApiDocument`, `parseJsonApiErrorBody`, `dispatchWithRetry`, `applyJsonApiHeaders`, `resolveResourcePath`, `parseMultiStatusBody`, `parseRetryAfterSeconds`, `assertValidIdempotencyKey`, …
|
|
580
457
|
|
|
581
|
-
|
|
458
|
+
Full list: [`src/index.ts`](./src/index.ts).
|
|
582
459
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
### Client
|
|
586
|
-
|
|
587
|
-
- `createApiClient(config)` → `ApiClient`
|
|
588
|
-
- Types: `ApiClient`, `ApiClientConfig`, `RequestCallOptions`, `AuthConfig`, `RetryOptions`, …
|
|
589
|
-
|
|
590
|
-
### Results & errors
|
|
591
|
-
|
|
592
|
-
- `ClientSuccess`, `JsonApiSuccessBody`, `NoContentBody`, `AcceptedBody`, `MultiStatusBody`
|
|
593
|
-
- `Result`, `OkResult`, `ErrResult`
|
|
594
|
-
- `ApiClientError`, `isApiClientError`
|
|
595
|
-
|
|
596
|
-
### JSON:API types
|
|
597
|
-
|
|
598
|
-
- `JsonApiDocument`, `JsonApiResourceObject`, `JsonApiErrorObject`, …
|
|
599
|
-
|
|
600
|
-
### Helpers
|
|
460
|
+
---
|
|
601
461
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
462
|
+
## Table of contents (full reference)
|
|
463
|
+
|
|
464
|
+
1. [What you get](#what-you-get)
|
|
465
|
+
2. [Install](#install)
|
|
466
|
+
3. [Quick start](#quick-start)
|
|
467
|
+
4. [Guide for application developers](#guide-for-application-developers)
|
|
468
|
+
5. [Recipes](#recipes)
|
|
469
|
+
6. [Configuration](#configuration)
|
|
470
|
+
7. [Making requests](#making-requests)
|
|
471
|
+
8. [Success results](#success-results-clientsuccess)
|
|
472
|
+
9. [Error handling](#error-handling)
|
|
473
|
+
10. [Idempotency](#idempotency)
|
|
474
|
+
11. [Optimistic concurrency](#optimistic-concurrency)
|
|
475
|
+
12. [Retries](#retries)
|
|
476
|
+
13. [More helpers](#more-helpers)
|
|
477
|
+
14. [Typing your API](#typing-your-api)
|
|
478
|
+
15. [Advanced exports](#advanced-exports)
|
|
479
|
+
16. [Development](#development-this-repository)
|
|
480
|
+
17. [Supply chain](#supply-chain)
|
|
481
|
+
18. [Publishing](#publishing-maintainers)
|
|
612
482
|
|
|
613
483
|
---
|
|
614
484
|
|
|
@@ -625,34 +495,29 @@ npm run build
|
|
|
625
495
|
npx vitest run --config vitest.postbuild.config.ts
|
|
626
496
|
```
|
|
627
497
|
|
|
628
|
-
CI uses **`node-version: '22.21'`** in
|
|
498
|
+
CI uses **`node-version: '22.21'`** in [`ci.yml`](.github/workflows/ci.yml) plus global **npm** `^11.5.1`.
|
|
629
499
|
|
|
630
500
|
---
|
|
631
501
|
|
|
632
502
|
## Supply chain
|
|
633
503
|
|
|
634
|
-
-
|
|
635
|
-
-
|
|
636
|
-
-
|
|
637
|
-
-
|
|
504
|
+
- [SECURITY.md](SECURITY.md) — reporting vulnerabilities
|
|
505
|
+
- [`.github/dependabot.yml`](.github/dependabot.yml) — weekly dependency PRs
|
|
506
|
+
- CI runs **`npm audit --omit=dev --audit-level=moderate`** on production deps
|
|
507
|
+
- Publish uses **`npm publish --provenance`** (OIDC)
|
|
638
508
|
|
|
639
509
|
---
|
|
640
510
|
|
|
641
511
|
## Publishing (maintainers)
|
|
642
512
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
2. Bump **`version`** in **`package.json`**, merge to **`main`**, then **Actions** → **Publish to npm** → **Run workflow** ( **`workflow_dispatch`** ).
|
|
647
|
-
|
|
648
|
-
Workflow permissions use **`contents: read`** and **`id-token: write`**; **[`publish-npm.yml`](.github/workflows/publish-npm.yml)** runs the same gates as **[`ci.yml`](.github/workflows/ci.yml)** (Node **`22.21`**, global **npm** **`^11.5.1`**) and then **`npm publish --provenance`**.
|
|
649
|
-
|
|
650
|
-
**Legacy (token publish):** If Trusted Publishing cannot be configured, restore a publish step env block with **`NODE_AUTH_TOKEN`** from a granular npm token (**Read and write** for this package; **Automation / bypass 2FA** if your npm account requires it for unattended publish). Prefer OIDC for supply-chain hygiene once enabled.
|
|
513
|
+
1. Bump **`version`** in **`package.json`** and update **[CHANGELOG.md](./CHANGELOG.md)**.
|
|
514
|
+
2. Merge to **`main`**, then **Actions → Publish to npm → Run workflow**.
|
|
515
|
+
3. Workflow fails fast if the version already exists on npm.
|
|
651
516
|
|
|
652
|
-
[`
|
|
517
|
+
[`publish-npm.yml`](.github/workflows/publish-npm.yml) runs the same gates as [`ci.yml`](.github/workflows/ci.yml) (audit, typecheck, lint, coverage, build, post-build tests), then publishes with provenance.
|
|
653
518
|
|
|
654
519
|
---
|
|
655
520
|
|
|
656
521
|
## License
|
|
657
522
|
|
|
658
|
-
MIT
|
|
523
|
+
MIT — see [LICENSE](./LICENSE).
|