@pureq/pureq 1.1.5 → 1.1.6
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 +47 -1125
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,1166 +1,88 @@
|
|
|
1
1
|
# pureq
|
|
2
2
|
|
|
3
|
-
Functional, immutable, type-safe HTTP transport layer for TypeScript
|
|
3
|
+
## Functional, immutable, and type-safe HTTP transport layer for TypeScript
|
|
4
4
|
|
|
5
|
-
[
|
|
5
|
+
[Get Started](./docs/getting_started.md) | [Documentation](./docs/README.md) | [Middleware Reference](./docs/middleware_reference.md) | [GitHub](https://github.com/shiro-shihi/pureq)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
pureq is a policy-first transport layer that makes HTTP behavior explicit, composable, and observable across frontend, BFF, backend, and edge runtimes. It is designed to replace ad-hoc fetch wrappers with a robust, immutable system for managing engineering-grade reliability.
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { createClient, retry, circuitBreaker, dedupe } from "pureq";
|
|
13
13
|
|
|
14
14
|
const api = createClient({ baseURL: "https://api.example.com" })
|
|
15
|
-
.use(dedupe())
|
|
16
|
-
.use(retry({ maxRetries: 2, delay: 200 }))
|
|
17
|
-
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }));
|
|
18
|
-
|
|
19
|
-
const user = await api.getJson<User>("/users/:id", { params: { id: "42" } });
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Zero runtime dependencies in the core package. Works everywhere `fetch` works.
|
|
23
|
-
|
|
24
|
-
## Install
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
npm install @pureq/pureq
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
### Node.js (with FileSystem support)
|
|
31
|
-
|
|
32
|
-
If using Node.js specific adapters (like `FileSystemQueueStorageAdapter`), use the node subpath:
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
import { FileSystemQueueStorageAdapter } from "pureq/node";
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
> [!NOTE]
|
|
39
|
-
> Core package (`pureq`) is zero-dependency and runtime-agnostic.
|
|
40
|
-
> Node-only adapters (for example `FileSystemQueueStorageAdapter`) live behind `pureq/node` so browser bundles stay clean and small.
|
|
41
|
-
|
|
42
|
-
## Quick Start
|
|
43
|
-
|
|
44
|
-
### A quicker start than raw fetch
|
|
45
|
-
|
|
46
|
-
```ts
|
|
47
|
-
import { createClient } from "pureq";
|
|
48
|
-
|
|
49
|
-
type User = { id: string; name: string };
|
|
50
|
-
|
|
51
|
-
const client = createClient({ baseURL: "https://api.example.com" });
|
|
52
|
-
|
|
53
|
-
// GET + status check + JSON parse + typed path params
|
|
54
|
-
const user = await client.getJson<User>("/users/:userId", {
|
|
55
|
-
params: { userId: "42" },
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
console.log(user.name);
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### Type-safe path parameters (Template Literal Types)
|
|
62
|
-
|
|
63
|
-
```ts
|
|
64
|
-
// Param keys are derived from the URL template at compile time.
|
|
65
|
-
|
|
66
|
-
// ✅ Compiles
|
|
67
|
-
await client.get("/users/:userId/posts/:postId", {
|
|
68
|
-
params: { userId: "1", postId: "42" },
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// ❌ TypeScript error: missing postId
|
|
72
|
-
await client.get("/users/:userId/posts/:postId", {
|
|
73
|
-
params: { userId: "1" },
|
|
74
|
-
});
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### Result API to prevent missed error handling
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
const result = await client.getJsonResult<User>("/users/:userId", {
|
|
81
|
-
params: { userId: "42" },
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
if (!result.ok) {
|
|
85
|
-
// Switching on kind gives explicit, exhaustive transport handling.
|
|
86
|
-
switch (result.error.kind) {
|
|
87
|
-
case "network":
|
|
88
|
-
showOfflineBanner();
|
|
89
|
-
break;
|
|
90
|
-
case "timeout":
|
|
91
|
-
showRetryToast();
|
|
92
|
-
break;
|
|
93
|
-
case "http":
|
|
94
|
-
if (result.error.status === 401) redirectToLogin();
|
|
95
|
-
break;
|
|
96
|
-
case "circuit-open":
|
|
97
|
-
switchToFallbackMode();
|
|
98
|
-
break;
|
|
99
|
-
case "aborted":
|
|
100
|
-
case "storage-error":
|
|
101
|
-
case "auth-error":
|
|
102
|
-
case "validation-error":
|
|
103
|
-
case "unknown":
|
|
104
|
-
reportTransportError(result.error);
|
|
105
|
-
break;
|
|
106
|
-
default: {
|
|
107
|
-
const exhaustive: never = result.error.kind;
|
|
108
|
-
throw new Error(`Unhandled error kind: ${exhaustive}`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// result.data is strongly typed JSON
|
|
115
|
-
renderUser(result.data);
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Every request method has a `*Result` variant that never throws — transport failures become values you can pattern-match on.
|
|
119
|
-
|
|
120
|
-
### Systematic Error Codes
|
|
121
|
-
|
|
122
|
-
For enterprise-grade observability, `PureqError` exposes both `kind` and `code` on the same error object. They represent the same category in two formats:
|
|
123
|
-
|
|
124
|
-
- `kind`: human-friendly lowercase string (e.g. `"timeout"`)
|
|
125
|
-
- `code`: machine-readable Screaming Snake Case (e.g. `"PUREQ_TIMEOUT"`)
|
|
126
|
-
|
|
127
|
-
```ts
|
|
128
|
-
if (!result.ok) {
|
|
129
|
-
if (result.error.kind === "timeout" && result.error.code === "PUREQ_TIMEOUT") {
|
|
130
|
-
// Handle specifically...
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Common codes: `PUREQ_TIMEOUT`, `PUREQ_NETWORK_ERROR`, `PUREQ_OFFLINE_QUEUE_FULL`, `PUREQ_AUTH_REFRESH_FAILED`, `PUREQ_VALIDATION_ERROR`.
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## Why pureq
|
|
140
|
-
|
|
141
|
-
### The problem with raw `fetch`
|
|
142
|
-
|
|
143
|
-
`fetch` is a primitive. It gives you a single request/response cycle and nothing else. Every team ends up rebuilding the same things on top of it:
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
// This is what real-world fetch code looks like
|
|
147
|
-
async function fetchUser(id: string) {
|
|
148
|
-
const controller = new AbortController();
|
|
149
|
-
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
const response = await fetch(`/api/users/${id}`, {
|
|
153
|
-
signal: controller.signal,
|
|
154
|
-
});
|
|
155
|
-
clearTimeout(timeout);
|
|
156
|
-
|
|
157
|
-
if (response.status === 429) {
|
|
158
|
-
// retry? how many times? what delay?
|
|
159
|
-
}
|
|
160
|
-
if (response.status >= 500) {
|
|
161
|
-
// retry? circuit break? log?
|
|
162
|
-
}
|
|
163
|
-
if (!response.ok) {
|
|
164
|
-
throw new Error(`HTTP ${response.status}`);
|
|
165
|
-
}
|
|
166
|
-
return await response.json();
|
|
167
|
-
} catch (err) {
|
|
168
|
-
clearTimeout(timeout);
|
|
169
|
-
// is it a timeout? network error? abort? how do we tell?
|
|
170
|
-
throw err;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
Every endpoint handler re-decides retry, timeout, error shape, and observability. There's no consistency, no composition, and no governance.
|
|
176
|
-
|
|
177
|
-
**pureq replaces that with:**
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
const api = createClient({ baseURL: "/api" })
|
|
181
|
-
.use(retry({ maxRetries: 2, delay: 200, retryOnStatus: [429, 500, 502, 503] }))
|
|
182
|
-
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }));
|
|
183
|
-
|
|
184
|
-
const user = await api.getJson<User>("/users/:id", {
|
|
185
|
-
params: { id },
|
|
186
|
-
timeout: 5000,
|
|
187
|
-
});
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
Policy is declared once, applied everywhere, and enforced by the type system.
|
|
191
|
-
|
|
192
|
-
### How pureq compares to axios
|
|
193
|
-
|
|
194
|
-
axios is familiar and battle-tested. But its mutability model makes transport behavior hard to reason about at scale:
|
|
195
|
-
|
|
196
|
-
| Concern | **axios** | **pureq** |
|
|
197
|
-
| --- | --- | --- |
|
|
198
|
-
| Client model | Mutable instances | Immutable — `.use()` returns new client |
|
|
199
|
-
| Retry/Circuit breaker | External packages (axios-retry, etc.) | Built-in middleware |
|
|
200
|
-
| Error model | Throws by default, boolean flags | `Result<T, E>` pattern — no exceptions |
|
|
201
|
-
| Path params | String interpolation | Type-safe `:param` templates |
|
|
202
|
-
| Middleware model | Interceptors (mutate config) | Onion middleware (compose behavior) |
|
|
203
|
-
| Policy guardrails | None | Validates invalid combinations at startup |
|
|
204
|
-
| Observability | Interceptor-based logging | Structured event hooks + OTel export |
|
|
205
|
-
| Bundle | ~14 KB gzipped + adapters | Zero-dependency, tree-shakeable |
|
|
206
|
-
|
|
207
|
-
pureq isn't "better" than axios universally. But if you want **explicit transport policy** that doesn't drift across a growing codebase, pureq is designed for that.
|
|
208
|
-
|
|
209
|
-
### pureq vs React Query / SWR
|
|
210
|
-
|
|
211
|
-
pureq does **not** replace React Query or SWR. They solve different problems:
|
|
212
|
-
|
|
213
|
-
| Concern | **React Query / SWR** | **pureq** |
|
|
214
|
-
| --- | --- | --- |
|
|
215
|
-
| Cache lifecycle | ✅ stale-while-revalidate, GC, refetch | ❌ not a UI cache |
|
|
216
|
-
| Query keys | ✅ declarative caching | ❌ |
|
|
217
|
-
| Suspense integration | ✅ | ❌ |
|
|
218
|
-
| UI state (loading/error) | ✅ | ❌ |
|
|
219
|
-
| Retry + backoff | ⚠️ basic | ✅ full control |
|
|
220
|
-
| Circuit breaker | ❌ | ✅ |
|
|
221
|
-
| Deadline propagation | ❌ | ✅ |
|
|
222
|
-
| Request dedup | ⚠️ by query key | ✅ by request signature |
|
|
223
|
-
| Concurrency limits | ❌ | ✅ |
|
|
224
|
-
| Hedged requests | ❌ | ✅ |
|
|
225
|
-
| Offline queue | ❌ | ✅ |
|
|
226
|
-
| Request observability | ❌ | ✅ OTel-ready |
|
|
227
|
-
| Idempotency keys | ❌ | ✅ |
|
|
228
|
-
| Multi-runtime | ⚠️ React-only | ✅ Any JS runtime |
|
|
229
|
-
|
|
230
|
-
**They compose perfectly together** — pureq is the transport layer *underneath* React Query:
|
|
231
|
-
|
|
232
|
-
```ts
|
|
233
|
-
import { useQuery } from "@tanstack/react-query";
|
|
234
|
-
import { createClient, retry, circuitBreaker } from "pureq";
|
|
235
|
-
|
|
236
|
-
const api = createClient({ baseURL: "https://api.example.com" })
|
|
237
|
-
.use(retry({ maxRetries: 2, delay: 200 }))
|
|
238
|
-
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }));
|
|
239
|
-
|
|
240
|
-
function useUser(id: string) {
|
|
241
|
-
return useQuery({
|
|
242
|
-
queryKey: ["user", id],
|
|
243
|
-
queryFn: () => api.getJson<User>("/users/:id", { params: { id } }),
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
React Query handles: cache lifecycle, stale-while-revalidate, refetching, and suspense.
|
|
249
|
-
pureq handles: retry strategy, circuit breaking, timeouts, dedup, and telemetry.
|
|
250
|
-
|
|
251
|
-
Clean separation. No overlap.
|
|
252
|
-
|
|
253
|
-
---
|
|
254
|
-
|
|
255
|
-
## Core Concepts
|
|
256
|
-
|
|
257
|
-
### Immutable client composition
|
|
258
|
-
|
|
259
|
-
Every call to `.use()`, `.useRequestInterceptor()`, or `.useResponseInterceptor()` returns a **new** client instance. The original is never mutated.
|
|
260
|
-
|
|
261
|
-
```ts
|
|
262
|
-
const base = createClient({ baseURL: "https://api.example.com" });
|
|
263
|
-
const withRetry = base.use(retry({ maxRetries: 2, delay: 200 }));
|
|
264
|
-
const withAuth = withRetry.useRequestInterceptor((req) => ({
|
|
265
|
-
...req,
|
|
266
|
-
headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
|
|
267
|
-
}));
|
|
268
|
-
|
|
269
|
-
// base, withRetry, and withAuth are three separate clients
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
This makes it trivial to share a base client while customizing per-dependency behavior.
|
|
273
|
-
|
|
274
|
-
### The "Why" of Immutability
|
|
275
|
-
|
|
276
|
-
Unlike mutable interceptor stacks, pureq clients are branchable snapshots.
|
|
277
|
-
|
|
278
|
-
```ts
|
|
279
|
-
import { authRefresh, createClient, dedupe, retry } from "pureq";
|
|
280
|
-
|
|
281
|
-
const base = createClient({ baseURL: "https://api.example.com" })
|
|
282
|
-
.use(retry({ maxRetries: 2, delay: 200 }));
|
|
283
|
-
|
|
284
|
-
// Public API: cache-friendly and lightweight
|
|
285
|
-
const publicApi = base.use(dedupe({ methods: ["GET", "HEAD"] }));
|
|
286
|
-
|
|
287
|
-
// Auth API: isolated auth policy without mutating publicApi
|
|
288
|
-
const authedApi = base
|
|
289
|
-
.use(authRefresh({ refresh: refreshToken }))
|
|
290
|
-
.useRequestInterceptor((req) => ({
|
|
291
|
-
...req,
|
|
292
|
-
headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
|
|
293
|
-
}));
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
This pattern is especially useful in BFFs: you keep shared defaults, then safely branch per upstream or trust boundary.
|
|
297
|
-
|
|
298
|
-
### Middleware: the Onion Model
|
|
299
|
-
|
|
300
|
-
Middleware wraps the entire request lifecycle. Each middleware can intercept the request before it happens, await the result, and post-process the response:
|
|
301
|
-
|
|
302
|
-
```text
|
|
303
|
-
Request
|
|
304
|
-
-> [dedupe]
|
|
305
|
-
-> [retry]
|
|
306
|
-
-> [circuitBreaker]
|
|
307
|
-
-> [adapter/fetch]
|
|
308
|
-
<- [circuitBreaker]
|
|
309
|
-
<- [retry]
|
|
310
|
-
<- [dedupe]
|
|
311
|
-
Response
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
Think of `retry` and `circuitBreaker` as wrappers around `next()`.
|
|
315
|
-
|
|
316
|
-
- Outer wrappers see the whole execution envelope.
|
|
317
|
-
- Inner wrappers only see what reaches them.
|
|
318
|
-
|
|
319
|
-
That is why order changes behavior. Example:
|
|
320
|
-
|
|
321
|
-
- `retry` outside `circuitBreaker`: each retry attempt passes through breaker logic.
|
|
322
|
-
- `circuitBreaker` outside `retry`: breaker sees one wrapped operation that may include internal retries.
|
|
323
|
-
|
|
324
|
-
Middleware can:
|
|
325
|
-
|
|
326
|
-
- Transform the request before `next()`
|
|
327
|
-
- Decide whether to call `next()` at all (e.g., cache hit, circuit open)
|
|
328
|
-
- Retry `next()` on failure (e.g., retry middleware)
|
|
329
|
-
- Transform the response after `next()`
|
|
330
|
-
|
|
331
|
-
### Interceptors vs Middleware
|
|
332
|
-
|
|
333
|
-
pureq distinctly separates lifecycle control from simple data transformation:
|
|
334
|
-
|
|
335
|
-
- **Middleware (Onion Model)**: Used for async control flow. Middleware wrappers can pause, retry, hedge, or completely short-circuit the network request (e.g., caching). They govern the *lifetime* and state of the request.
|
|
336
|
-
- **Interceptors**: Used for pure data transformation. `useRequestInterceptor` and `useResponseInterceptor` are lightweight hooks to modify the shape of the request or response (e.g., synchronously adding a token header) without the boilerplate of managing async state or the `next()` function cascade.
|
|
337
|
-
|
|
338
|
-
### Type-safe path parameters
|
|
339
|
-
|
|
340
|
-
Route templates like `/users/:userId/posts/:postId` are type-checked at compile time:
|
|
341
|
-
|
|
342
|
-
```ts
|
|
343
|
-
// ✅ Compiles — params match the URL template
|
|
344
|
-
await client.get("/users/:userId/posts/:postId", {
|
|
345
|
-
params: { userId: "1", postId: "42" },
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
// ❌ TypeScript error — missing 'postId'
|
|
349
|
-
await client.get("/users/:userId/posts/:postId", {
|
|
350
|
-
params: { userId: "1" },
|
|
351
|
-
});
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
### Result-based error handling
|
|
355
|
-
|
|
356
|
-
Instead of `try/catch` everywhere, use the `*Result` variants for explicit error handling:
|
|
357
|
-
|
|
358
|
-
```ts
|
|
359
|
-
const result = await client.postResult("/orders", orderData);
|
|
360
|
-
|
|
361
|
-
if (!result.ok) {
|
|
362
|
-
switch (result.error.kind) {
|
|
363
|
-
case "network":
|
|
364
|
-
showOfflineNotice();
|
|
365
|
-
break;
|
|
366
|
-
case "timeout":
|
|
367
|
-
showRetryPrompt();
|
|
368
|
-
break;
|
|
369
|
-
case "circuit-open":
|
|
370
|
-
showDegradedMode();
|
|
371
|
-
break;
|
|
372
|
-
default:
|
|
373
|
-
logError(result.error);
|
|
374
|
-
}
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// result.data is HttpResponse
|
|
379
|
-
const order = await result.data.json<Order>();
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
### Streams and Binary Data
|
|
383
|
-
|
|
384
|
-
When reading payloads via `.arrayBuffer()`, `.blob()`, or `response.body` (`ReadableStream`), standard `fetch` semantics dictate that the initial Promise resolves as soon as HTTP headers are received.
|
|
385
|
-
|
|
386
|
-
pureq inherently protects the *entire stream lifecycle*. Middleware policies like `deadline()` or `defaultTimeout()` bind an `AbortSignal` to the underlying `fetch`. If a timeout is exceeded while actively downloading the body stream off the network, the signal propagates down and automatically aborts the stream, preventing memory and resource leaks safely.
|
|
387
|
-
|
|
388
|
-
---
|
|
389
|
-
|
|
390
|
-
## Reliability Middleware
|
|
391
|
-
|
|
392
|
-
All middleware is composable. Stack them in the order you want:
|
|
393
|
-
|
|
394
|
-
```ts
|
|
395
|
-
const client = createClient({ baseURL: "https://api.example.com" })
|
|
396
15
|
.use(dedupe()) // collapse duplicate in-flight GETs
|
|
397
|
-
.use(
|
|
398
|
-
.use(
|
|
399
|
-
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }))
|
|
400
|
-
.use(validation({ validate: (data) => !!data })) // zero-dependency schema validation
|
|
401
|
-
.use(fallback({ value: { body: "default", status: 200 } as any })); // graceful degradation
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
### Standard Order (recommended)
|
|
405
|
-
|
|
406
|
-
For most production stacks, this order is a safe default:
|
|
407
|
-
|
|
408
|
-
```text
|
|
409
|
-
[dedupe] -> [httpCache] -> [retry] -> [circuitBreaker] -> [validation] -> [adapter]
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
Why this order:
|
|
413
|
-
|
|
414
|
-
- `dedupe` outermost to collapse duplicate in-flight work early.
|
|
415
|
-
- `httpCache` before retry to short-circuit cache hits.
|
|
416
|
-
- `retry` before `circuitBreaker` so each attempt is still guarded by breaker state.
|
|
417
|
-
- `validation` near the inner edge to validate final payloads before returning to callers.
|
|
418
|
-
|
|
419
|
-
If order is reversed accidentally, behavior can drift (for example, retrying inside a layer you expected to run once).
|
|
420
|
-
|
|
421
|
-
### Retry
|
|
422
|
-
|
|
423
|
-
Full-featured retry with exponential backoff, Retry-After header respect, and retry budget.
|
|
424
|
-
|
|
425
|
-
```ts
|
|
426
|
-
import { retry } from "pureq";
|
|
427
|
-
|
|
428
|
-
client.use(retry({
|
|
429
|
-
maxRetries: 3,
|
|
430
|
-
delay: 200,
|
|
431
|
-
methods: ["GET", "PUT", "DELETE"], // Safe defaults: idempotent methods only
|
|
432
|
-
backoff: true,
|
|
433
|
-
onRetry: ({ attempt, error }) => console.warn(`Retry #${attempt}`, error),
|
|
434
|
-
}));
|
|
435
|
-
```
|
|
436
|
-
|
|
437
|
-
**Safety First**: By default, `retry` only targets idempotent methods. To retry `POST` or `PATCH`, you must explicitly add them to `methods` and ensure the backend supports idempotency keys.
|
|
438
|
-
|
|
439
|
-
```text
|
|
440
|
-
POST /api/orders HTTP/1.1
|
|
441
|
-
Idempotency-Key: abc-123
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
### Deadline Propagation
|
|
445
|
-
|
|
446
|
-
Enforces a total request budget across all retry attempts — different from a per-request timeout.
|
|
447
|
-
|
|
448
|
-
```ts
|
|
449
|
-
import { deadline, retry } from "pureq";
|
|
450
|
-
|
|
451
|
-
const client = createClient()
|
|
452
|
-
.use(deadline({ defaultTimeoutMs: 1500 })) // 1.5s total, no matter how many retries
|
|
453
|
-
.use(retry({ maxRetries: 3, delay: 200 }));
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
### Circuit Breaker
|
|
457
|
-
|
|
458
|
-
Stops sending requests to a failing dependency. Automatically probes for recovery.
|
|
459
|
-
|
|
460
|
-
```ts
|
|
461
|
-
import { circuitBreaker } from "pureq";
|
|
462
|
-
|
|
463
|
-
client.use(circuitBreaker({
|
|
464
|
-
failureThreshold: 5, // open after 5 consecutive failures
|
|
465
|
-
successThreshold: 2, // close after 2 successes in half-open
|
|
466
|
-
cooldownMs: 30_000, // probe again after 30s
|
|
467
|
-
}));
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
### Concurrency Limit
|
|
471
|
-
|
|
472
|
-
Caps in-flight requests globally or by key to protect backend resources.
|
|
473
|
-
|
|
474
|
-
```ts
|
|
475
|
-
import { concurrencyLimit } from "pureq";
|
|
476
|
-
|
|
477
|
-
client.use(concurrencyLimit({
|
|
478
|
-
maxConcurrent: 20,
|
|
479
|
-
keyBuilder: (req) => new URL(req.url).hostname,
|
|
480
|
-
}));
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
### Hedged Requests
|
|
484
|
-
|
|
485
|
-
Issues a duplicate request after a short delay for tail-latency-sensitive reads. The first response wins; the loser is aborted.
|
|
486
|
-
|
|
487
|
-
```ts
|
|
488
|
-
import { hedge } from "pureq";
|
|
489
|
-
|
|
490
|
-
client.use(hedge({
|
|
491
|
-
hedgeAfterMs: 80,
|
|
492
|
-
methods: ["GET"],
|
|
493
|
-
}));
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
### Request Deduplication
|
|
497
|
-
|
|
498
|
-
Collapses concurrent duplicate GET requests into a single in-flight call.
|
|
499
|
-
|
|
500
|
-
```ts
|
|
501
|
-
import { dedupe } from "pureq";
|
|
502
|
-
|
|
503
|
-
client.use(dedupe({
|
|
504
|
-
methods: ["GET", "HEAD"],
|
|
505
|
-
}));
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
### HTTP Cache
|
|
509
|
-
|
|
510
|
-
In-memory cache with ETag revalidation and stale-if-error fallback.
|
|
511
|
-
|
|
512
|
-
```ts
|
|
513
|
-
import { httpCache } from "pureq";
|
|
514
|
-
|
|
515
|
-
client.use(httpCache({
|
|
516
|
-
ttlMs: 10_000,
|
|
517
|
-
staleIfErrorMs: 60_000,
|
|
518
|
-
maxEntries: 500, // LRU eviction when full
|
|
519
|
-
}));
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
### Offline Queue
|
|
523
|
-
|
|
524
|
-
Queues mutation requests when offline and replays them when connectivity restores.
|
|
525
|
-
|
|
526
|
-
```ts
|
|
527
|
-
import { createOfflineQueue, idempotencyKey } from "pureq";
|
|
528
|
-
|
|
529
|
-
// 1. Create durable storage (IndexedDB for browser, FS for Node)
|
|
530
|
-
const storage = new IndexedDBQueueStorageAdapter();
|
|
16
|
+
.use(retry({ maxRetries: 2, delay: 200 })) // exponential backoff
|
|
17
|
+
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 })); // stop on outages
|
|
531
18
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
const encryptedStorage = new EncryptedQueueStorageAdapter(storage, myCryptoKey);
|
|
535
|
-
|
|
536
|
-
const queue = createOfflineQueue({
|
|
537
|
-
storage: encryptedStorage,
|
|
538
|
-
methods: ["POST", "PUT", "PATCH"],
|
|
539
|
-
ttlMs: 24 * 60 * 60 * 1000, // 24h expiration
|
|
540
|
-
lockName: "my-app-offline-lock", // Multi-tab coordination
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
const client = createClient()
|
|
544
|
-
.use(idempotencyKey())
|
|
545
|
-
.use(queue.middleware);
|
|
546
|
-
|
|
547
|
-
// Later, when back online:
|
|
548
|
-
await queue.flush((req) => client.post(req.url, req.body));
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
**Durable Adapters**:
|
|
552
|
-
|
|
553
|
-
- `IndexedDBQueueStorageAdapter`: Standard browser persistence.
|
|
554
|
-
- `FileSystemQueueStorageAdapter`: Node.js persistence (import from `pureq/node`).
|
|
555
|
-
- `EncryptedQueueStorageAdapter`: Wrapper that encrypts data at rest using AES-GCM.
|
|
556
|
-
|
|
557
|
-
```ts
|
|
558
|
-
// Example: Creating an encrypted storage
|
|
559
|
-
const encrypted = new EncryptedQueueStorageAdapter(new IndexedDBQueueStorageAdapter(), key);
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
### Idempotency Keys
|
|
563
|
-
|
|
564
|
-
Automatically injects idempotency key headers for mutation requests.
|
|
565
|
-
|
|
566
|
-
```ts
|
|
567
|
-
import { idempotencyKey } from "pureq";
|
|
568
|
-
|
|
569
|
-
client.use(idempotencyKey({
|
|
570
|
-
headerName: "Idempotency-Key", // default
|
|
571
|
-
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
572
|
-
}));
|
|
573
|
-
```
|
|
574
|
-
|
|
575
|
-
### Auth Refresh
|
|
576
|
-
|
|
577
|
-
Automatically handles 401 Unauthorized errors by refreshing the token and retrying the request. Includes built-in "thundering herd" prevention to ensure only one refresh request is in-flight at a time.
|
|
578
|
-
|
|
579
|
-
```ts
|
|
580
|
-
import { authRefresh } from "pureq";
|
|
581
|
-
|
|
582
|
-
client.use(authRefresh({
|
|
583
|
-
refresh: async () => {
|
|
584
|
-
// Multiple 401s will only trigger ONE refresh call.
|
|
585
|
-
const res = await fetch("/api/refresh", { method: "POST" });
|
|
586
|
-
return (await res.json()).token;
|
|
587
|
-
},
|
|
588
|
-
// Optional: customize how the request is updated
|
|
589
|
-
updateRequest: (req, token) => ({
|
|
590
|
-
...req,
|
|
591
|
-
headers: { ...req.headers, Authorization: `Bearer ${token}` }
|
|
592
|
-
})
|
|
593
|
-
}));
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
### Validation
|
|
597
|
-
|
|
598
|
-
A zero-dependency bridge to any schema validation library (Zod, Valibot, etc.) or custom type guards.
|
|
599
|
-
|
|
600
|
-
```ts
|
|
601
|
-
import { validation } from "pureq";
|
|
602
|
-
import { z } from "zod";
|
|
603
|
-
|
|
604
|
-
const UserSchema = z.object({ id: z.string(), name: z.string() });
|
|
605
|
-
|
|
606
|
-
client.use(validation({
|
|
607
|
-
validate: (data) => UserSchema.parse(data), // Throws PUREQ_VALIDATION_ERROR on failure
|
|
608
|
-
message: "Invalid API response schema"
|
|
609
|
-
}));
|
|
19
|
+
// GET + status check + JSON parse + typed path params
|
|
20
|
+
const user = await api.getJson<User>("/users/:id", { params: { id: "42" } });
|
|
610
21
|
```
|
|
611
22
|
|
|
612
|
-
|
|
23
|
+
## Philosophy
|
|
613
24
|
|
|
614
|
-
|
|
25
|
+
The core philosophy of pureq is that **transport is a policy**. Communication between services should not be hidden behind imperative logic or mutable configurations. Instead, pureq treats reliability, observability, and security as first-class citizens that are composed as immutable layers.
|
|
615
26
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const isPureqError = (value: unknown): value is PureqError => {
|
|
620
|
-
return typeof value === "object" && value !== null && "code" in value && "kind" in value;
|
|
621
|
-
};
|
|
27
|
+
- **Immutability as Safety**: Every change to a client returns a new instance, preventing side-effects across shared infrastructure.
|
|
28
|
+
- **Failures as Values**: Through the Result pattern, errors are treated as data to be handled explicitly, not exceptions that disrupt the flow.
|
|
29
|
+
- **Policy over Code**: Reliability logic (retries, timeouts, breakers) is declared as a policy stack, separated from the business logic of individual requests.
|
|
622
30
|
|
|
623
|
-
|
|
624
|
-
value: new HttpResponse(new Response(JSON.stringify({ items: [] }), { status: 200 })),
|
|
625
|
-
when: (trigger) =>
|
|
626
|
-
trigger.type === "error" &&
|
|
627
|
-
isPureqError(trigger.error) &&
|
|
628
|
-
trigger.error.code === "PUREQ_TIMEOUT" &&
|
|
629
|
-
trigger.error.kind === "timeout"
|
|
630
|
-
}));
|
|
631
|
-
```
|
|
31
|
+
## Key Features
|
|
632
32
|
|
|
633
|
-
|
|
33
|
+
- **Immutable Client Composition**: Safely branch and share transport configurations without mutation leaks.
|
|
34
|
+
- **Onion Model Middleware**: Powerful, composable async lifecycle control for retries, caching, and circuit breaking.
|
|
35
|
+
- **Strictly Type-Safe**: Compile-time validation for URL path parameters and response schema structures.
|
|
36
|
+
- **Non-throwing API**: Native support for the Result pattern to ensure exhaustive error handling.
|
|
37
|
+
- **Zero Runtime Dependencies**: Ultra-lightweight core that works in any JS environment (Node, Browser, Bun, Edge).
|
|
38
|
+
- **Enterprise Observability**: Built-in policy tracing, performance metrics, and OpenTelemetry mapping.
|
|
634
39
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
```ts
|
|
638
|
-
// ❌ Throws: "pureq: multiple retry policies are not allowed in one client"
|
|
639
|
-
createClient()
|
|
640
|
-
.use(retry({ maxRetries: 2, delay: 200 }))
|
|
641
|
-
.use(retry({ maxRetries: 3, delay: 300 }));
|
|
642
|
-
|
|
643
|
-
// ❌ Throws: "pureq: use deadline or defaultTimeout, not both"
|
|
644
|
-
createClient()
|
|
645
|
-
.use(deadline({ defaultTimeoutMs: 1500 }))
|
|
646
|
-
.use(defaultTimeout(3000));
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
---
|
|
40
|
+
## Documentation Index
|
|
650
41
|
|
|
651
|
-
|
|
42
|
+
| Guide | Description |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| [Getting Started](./docs/getting_started.md) | Installation and your first request |
|
|
45
|
+
| [Core Concepts](./docs/core_concepts.md) | Immutability, the Onion Model, and Path Typing |
|
|
46
|
+
| [Middleware Reference](./docs/middleware_reference.md) | Detailed guide for all reliability policies |
|
|
47
|
+
| [Error Handling](./docs/error_handling.md) | The Result pattern and Error codes reference |
|
|
48
|
+
| [Observability](./docs/observability.md) | Lifecycle hooks, metrics, and OTel integration |
|
|
49
|
+
| [Integrations](./docs/README.md#integrations) | React Query, SWR, and Backend patterns |
|
|
652
50
|
|
|
653
|
-
|
|
51
|
+
## Installation
|
|
654
52
|
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
// Frontend: conservative retries, short timeout, dedup for GETs
|
|
659
|
-
let frontend = createClient();
|
|
660
|
-
for (const mw of frontendPreset()) frontend = frontend.use(mw);
|
|
661
|
-
|
|
662
|
-
// BFF: balanced latency vs stability, idempotency for mutations
|
|
663
|
-
let bff = createClient();
|
|
664
|
-
for (const mw of bffPreset()) bff = bff.use(mw);
|
|
665
|
-
|
|
666
|
-
// Backend: aggressive retries, circuit breaker, no dedup
|
|
667
|
-
let backend = createClient();
|
|
668
|
-
for (const mw of backendPreset()) backend = backend.use(mw);
|
|
53
|
+
```bash
|
|
54
|
+
npm install @pureq/pureq
|
|
669
55
|
```
|
|
670
56
|
|
|
671
|
-
|
|
672
|
-
| ------ | ------- | ------- | ----- | --------------- | ----------- |
|
|
673
|
-
| `frontendPreset()` | 5s | 1 | GET/HEAD | 4 failures / 10s cooldown | ✅ |
|
|
674
|
-
| `bffPreset()` | 3s | 2 | GET/HEAD | 5 failures / 20s cooldown | ✅ body-only |
|
|
675
|
-
| `backendPreset()` | 2.5s | 3 | off | 6 failures / 30s cooldown | ✅ body-only |
|
|
676
|
-
| `resilientPreset()` | none (pair with `defaultTimeout()` if needed) | 2 | GET/HEAD | 5 failures / 30s cooldown | ✅ |
|
|
677
|
-
|
|
678
|
-
All presets are built from the same public middleware. You can inspect and override any parameter.
|
|
679
|
-
|
|
680
|
-
---
|
|
681
|
-
|
|
682
|
-
## Observability
|
|
683
|
-
|
|
684
|
-
### Client lifecycle hooks
|
|
57
|
+
For Node.js specific adapters (FileSystem storage, etc.), use the node subpath:
|
|
685
58
|
|
|
686
59
|
```ts
|
|
687
|
-
|
|
688
|
-
hooks: {
|
|
689
|
-
onRequestStart: (event) => {
|
|
690
|
-
console.log(`→ ${event.method} ${event.url} [${event.requestId}]`);
|
|
691
|
-
},
|
|
692
|
-
onRequestSuccess: (event) => {
|
|
693
|
-
console.log(`✓ ${event.status} in ${event.latencyMs}ms [retries: ${event.retryCount}]`);
|
|
694
|
-
},
|
|
695
|
-
onRequestError: (event) => {
|
|
696
|
-
console.error(`✗ ${event.errorKind}: ${event.error.message}`);
|
|
697
|
-
},
|
|
698
|
-
},
|
|
699
|
-
});
|
|
60
|
+
import { FileSystemQueueStorageAdapter } from "pureq/node";
|
|
700
61
|
```
|
|
701
62
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
Collect per-request performance metrics and policy traces:
|
|
63
|
+
## Usage Example
|
|
705
64
|
|
|
706
65
|
```ts
|
|
707
|
-
import {
|
|
66
|
+
import { createClient, frontendPreset } from "pureq";
|
|
708
67
|
|
|
709
|
-
|
|
710
|
-
|
|
68
|
+
// Initialize with production-ready defaults
|
|
69
|
+
const api = createClient({
|
|
70
|
+
baseURL: "https://api.example.com",
|
|
71
|
+
middlewares: frontendPreset(),
|
|
711
72
|
});
|
|
712
73
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const snap = diagnostics.snapshot();
|
|
717
|
-
console.log(snap.p50, snap.p95, snap.total, snap.success, snap.failed);
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
### Policy Tracing (Debuggability)
|
|
721
|
-
|
|
722
|
-
Ever wonder *why* a request was retried or why the circuit opened? `pureq` records a detailed decision trace for every request.
|
|
723
|
-
|
|
724
|
-
```ts
|
|
725
|
-
import { explainPolicyTrace } from "pureq";
|
|
726
|
-
|
|
727
|
-
try {
|
|
728
|
-
await client.get("/flakey-endpoint");
|
|
729
|
-
} catch (err) {
|
|
730
|
-
// Prints exactly what happened:
|
|
731
|
-
// [2026-04-10T10:00:00Z] RETRY: RETRY (status=503)
|
|
732
|
-
// [2026-04-10T10:00:01Z] RETRY: RETRY (status=503)
|
|
733
|
-
// [2026-04-10T10:00:02Z] CIRCUIT-BREAKER: TRIP (reason="failure threshold exceeded")
|
|
734
|
-
console.log(explainPolicyTrace(err.request));
|
|
735
|
-
}
|
|
736
|
-
```
|
|
737
|
-
|
|
738
|
-
### OpenTelemetry integration
|
|
739
|
-
|
|
740
|
-
Map transport events to OTel-compatible attributes:
|
|
741
|
-
|
|
742
|
-
```ts
|
|
743
|
-
import {
|
|
744
|
-
mapToStandardHttpAttributes,
|
|
745
|
-
mapToAwsSemanticConventions,
|
|
746
|
-
mapToGcpSemanticConventions,
|
|
747
|
-
} from "pureq";
|
|
748
|
-
|
|
749
|
-
// Standard OTel HTTP semantic conventions
|
|
750
|
-
const attrs = mapToStandardHttpAttributes(event);
|
|
751
|
-
// { "http.method": "GET", "http.url": "...", "http.status_code": 200, ... }
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
### Redaction for safe telemetry
|
|
755
|
-
|
|
756
|
-
Built-in redaction profiles prevent sensitive data from leaking into logs and telemetry:
|
|
757
|
-
|
|
758
|
-
```ts
|
|
759
|
-
import {
|
|
760
|
-
redactHeaders,
|
|
761
|
-
redactObjectFields,
|
|
762
|
-
redactUrlQueryParams,
|
|
763
|
-
piiRedactionOptions,
|
|
764
|
-
authRedactionOptions,
|
|
765
|
-
} from "pureq";
|
|
766
|
-
|
|
767
|
-
redactHeaders(headers);
|
|
768
|
-
// Authorization: "[REDACTED]", Cookie: "[REDACTED]", ...
|
|
769
|
-
|
|
770
|
-
redactUrlQueryParams("https://api.example.com/v1?token=secret123&page=1");
|
|
771
|
-
// "https://api.example.com/v1?token=[REDACTED]&page=1"
|
|
772
|
-
|
|
773
|
-
redactObjectFields(body, piiRedactionOptions);
|
|
774
|
-
// { email: "[REDACTED]", phone: "[REDACTED]", name: "Alice" }
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
---
|
|
778
|
-
|
|
779
|
-
## React Query / SWR Integration
|
|
780
|
-
|
|
781
|
-
### React Query
|
|
782
|
-
|
|
783
|
-
```ts
|
|
784
|
-
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
785
|
-
import { createClient, retry } from "pureq";
|
|
786
|
-
|
|
787
|
-
const api = createClient({ baseURL: "https://api.example.com" })
|
|
788
|
-
.use(retry({ maxRetries: 2, delay: 200 }));
|
|
789
|
-
|
|
790
|
-
// Queries
|
|
791
|
-
function useUser(id: string) {
|
|
792
|
-
return useQuery({
|
|
793
|
-
queryKey: ["user", id],
|
|
794
|
-
queryFn: () => api.getJson<User>("/users/:id", { params: { id } }),
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Mutations
|
|
799
|
-
function useCreatePost() {
|
|
800
|
-
return useMutation({
|
|
801
|
-
mutationFn: (data: CreatePostInput) =>
|
|
802
|
-
api.postJson<Post>("/posts", data),
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
### SWR
|
|
808
|
-
|
|
809
|
-
```ts
|
|
810
|
-
import useSWR from "swr";
|
|
811
|
-
import { createClient } from "pureq";
|
|
812
|
-
|
|
813
|
-
const api = createClient({ baseURL: "https://api.example.com" });
|
|
814
|
-
|
|
815
|
-
function useUser(id: string) {
|
|
816
|
-
return useSWR(
|
|
817
|
-
["user", id],
|
|
818
|
-
() => api.getJson<User>("/users/:id", { params: { id } }),
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
```
|
|
822
|
-
|
|
823
|
-
**Separation of concerns:**
|
|
824
|
-
|
|
825
|
-
- **React Query / SWR** → cache lifecycle, stale-while-revalidate, background refetch, suspense, UI state
|
|
826
|
-
- **pureq** → retry, circuit breaking, timeout, dedup, concurrency, telemetry, error normalization
|
|
827
|
-
|
|
828
|
-
You get the best of both worlds.
|
|
829
|
-
|
|
830
|
-
---
|
|
831
|
-
|
|
832
|
-
## BFF / Backend Patterns
|
|
833
|
-
|
|
834
|
-
### BFF (Backend-For-Frontend)
|
|
835
|
-
|
|
836
|
-
A BFF aggregates multiple upstream APIs for the frontend. pureq gives you explicit per-dependency policy:
|
|
837
|
-
|
|
838
|
-
```ts
|
|
839
|
-
// One client per upstream service — explicit, isolated policies
|
|
840
|
-
const userService = createClient({ baseURL: "https://user-service.internal" })
|
|
841
|
-
.use(retry({ maxRetries: 2, delay: 150 }))
|
|
842
|
-
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 20_000 }))
|
|
843
|
-
.useRequestInterceptor((req) => ({
|
|
844
|
-
...req,
|
|
845
|
-
headers: { ...req.headers, "X-Internal-Auth": getServiceToken() },
|
|
846
|
-
}));
|
|
847
|
-
|
|
848
|
-
const paymentService = createClient({ baseURL: "https://payment-service.internal" })
|
|
849
|
-
.use(retry({ maxRetries: 1, delay: 500 }))
|
|
850
|
-
.use(circuitBreaker({ failureThreshold: 3, cooldownMs: 60_000 }));
|
|
851
|
-
|
|
852
|
-
// BFF handler
|
|
853
|
-
async function handleGetUserProfile(userId: string) {
|
|
854
|
-
const user = await userService.getJson<User>("/users/:id", {
|
|
855
|
-
params: { id: userId },
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
const paymentResult = await paymentService.getResult("/payments/user/:id", {
|
|
859
|
-
params: { id: userId },
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
return {
|
|
863
|
-
...user,
|
|
864
|
-
payments: paymentResult.ok ? await paymentResult.data.json() : [],
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
```
|
|
868
|
-
|
|
869
|
-
### Backend service-to-service
|
|
870
|
-
|
|
871
|
-
```ts
|
|
872
|
-
import { createClient, backendPreset } from "pureq";
|
|
873
|
-
|
|
874
|
-
let inventoryClient = createClient({
|
|
875
|
-
baseURL: "https://inventory.internal",
|
|
876
|
-
hooks: {
|
|
877
|
-
onRequestError: (event) => {
|
|
878
|
-
metrics.increment("inventory.request.error", { kind: event.errorKind });
|
|
879
|
-
},
|
|
880
|
-
},
|
|
74
|
+
// Type-safe GET request
|
|
75
|
+
const result = await api.getJsonResult<User>("/users/:id", {
|
|
76
|
+
params: { id: "42" }
|
|
881
77
|
});
|
|
882
78
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
inventoryClient = inventoryClient.use(mw);
|
|
79
|
+
if (result.ok) {
|
|
80
|
+
console.log(result.data.name);
|
|
81
|
+
} else {
|
|
82
|
+
console.error(result.error.kind); // e.g., 'timeout', 'network', 'http'
|
|
888
83
|
}
|
|
889
84
|
```
|
|
890
85
|
|
|
891
|
-
---
|
|
892
|
-
|
|
893
|
-
## Adapters & Serializers
|
|
894
|
-
|
|
895
|
-
### Custom adapters
|
|
896
|
-
|
|
897
|
-
The default adapter uses the global `fetch`. You can swap it for tests or runtime-specific behavior:
|
|
898
|
-
|
|
899
|
-
```ts
|
|
900
|
-
// Test adapter
|
|
901
|
-
const testClient = createClient({
|
|
902
|
-
adapter: async (url, init) => {
|
|
903
|
-
return new Response(JSON.stringify({ ok: true }), {
|
|
904
|
-
status: 200,
|
|
905
|
-
headers: { "Content-Type": "application/json" },
|
|
906
|
-
});
|
|
907
|
-
},
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
// Instrumented adapter
|
|
911
|
-
import { createInstrumentedAdapter, fetchAdapter } from "pureq";
|
|
912
|
-
|
|
913
|
-
const instrumented = createInstrumentedAdapter(fetchAdapter, {
|
|
914
|
-
onStart: (e) => console.log(`→ ${e.url}`),
|
|
915
|
-
onSuccess: (e) => console.log(`✓ ${e.durationMs}ms`),
|
|
916
|
-
onError: (e) => console.error(`✗ ${e.error}`),
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
const client = createClient({ adapter: instrumented });
|
|
920
|
-
```
|
|
921
|
-
|
|
922
|
-
### Body serializers
|
|
923
|
-
|
|
924
|
-
```ts
|
|
925
|
-
import { createFormUrlEncodedSerializer } from "pureq";
|
|
926
|
-
|
|
927
|
-
const client = createClient({
|
|
928
|
-
bodySerializer: createFormUrlEncodedSerializer({ arrayMode: "comma" }),
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
await client.post("/search", { tags: ["ts", "http"], q: "pureq" });
|
|
932
|
-
// body: tags=ts%2Chttp&q=pureq
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
### Binary Protocols (MessagePack, Protobuf)
|
|
936
|
-
|
|
937
|
-
pureq was designed to be a highly extensible transport layer. To maintain a zero-dependency footprint, we do not bundle heavy binary decoders into the core. However, because pureq fully supports standard `fetch` primitives like `Uint8Array`, integrating binary protocols is natively supported today:
|
|
938
|
-
|
|
939
|
-
```ts
|
|
940
|
-
import { encode } from "@msgpack/msgpack";
|
|
941
|
-
|
|
942
|
-
// You can swap the body serializer to return binary formats
|
|
943
|
-
const msgpackSerializer = (data: unknown) => ({
|
|
944
|
-
body: encode(data), // Returns Uint8Array
|
|
945
|
-
headers: { "Content-Type": "application/x-msgpack" }
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
const client = createClient({ bodySerializer: msgpackSerializer });
|
|
949
|
-
```
|
|
950
|
-
|
|
951
|
-
**Ecosystem Vision**: Moving forward, rather than bloating the core, we plan to provide official plugin packages like `@pureq/plugin-msgpack` or `@pureq/serialize-protobuf`. This ensures the core model remains pure and lightweight while scaling to meet extreme performance requirements.
|
|
952
|
-
|
|
953
|
-
---
|
|
954
|
-
|
|
955
|
-
## Migration from fetch / axios
|
|
956
|
-
|
|
957
|
-
The smallest possible migration:
|
|
958
|
-
|
|
959
|
-
```ts
|
|
960
|
-
// Before (fetch)
|
|
961
|
-
const response = await fetch("https://api.example.com/users/42");
|
|
962
|
-
const user = await response.json();
|
|
963
|
-
|
|
964
|
-
// After (pureq)
|
|
965
|
-
import { createClient } from "pureq";
|
|
966
|
-
const client = createClient({ baseURL: "https://api.example.com" });
|
|
967
|
-
const user = await client.getJson<User>("/users/:id", { params: { id: "42" } });
|
|
968
|
-
```
|
|
969
|
-
|
|
970
|
-
### Step-by-step adoption
|
|
971
|
-
|
|
972
|
-
1. **Wrap one API dependency** with `createClient()` — just `baseURL` and `headers`
|
|
973
|
-
2. **Replace repeated `fetch` calls** with `client.get()` / `client.post()`
|
|
974
|
-
3. **Move retry/timeout logic** from ad-hoc `try/catch` into middleware
|
|
975
|
-
4. **Use `*Result` variants** where you want explicit error handling
|
|
976
|
-
5. **Add diagnostics** to gain observability without changing call sites
|
|
977
|
-
6. **Only then** add caching, hedging, circuit breaking, guardrails, or presets
|
|
978
|
-
|
|
979
|
-
Each step is independently useful. You don't need everything on day one.
|
|
980
|
-
|
|
981
|
-
More details: [Migration guide](./docs/migration_guide.md) · [Codemod recipes](./tools/codemods/README.md)
|
|
982
|
-
|
|
983
|
-
---
|
|
984
|
-
|
|
985
|
-
## Runtime Compatibility
|
|
986
|
-
|
|
987
|
-
pureq works anywhere `fetch` is available:
|
|
988
|
-
|
|
989
|
-
| Runtime | Supported | Tested |
|
|
990
|
-
| ------- | --------- | ------ |
|
|
991
|
-
| Node.js 18+ | ✅ | CI matrix (18, 20, 22) |
|
|
992
|
-
| Deno | ✅ | — |
|
|
993
|
-
| Bun | ✅ | — |
|
|
994
|
-
| Modern browsers | ✅ | jsdom smoke test |
|
|
995
|
-
| Cloudflare Workers | ✅ | Edge runtime smoke test |
|
|
996
|
-
| Vercel Edge | ✅ | Edge runtime smoke test |
|
|
997
|
-
|
|
998
|
-
Zero dependencies. ESM-first. Tree-shakeable.
|
|
999
|
-
|
|
1000
|
-
More details: [Runtime compatibility matrix](./docs/runtime_compatibility_matrix.md)
|
|
1001
|
-
|
|
1002
|
-
---
|
|
1003
|
-
|
|
1004
|
-
## Production Readiness Checklist
|
|
1005
|
-
|
|
1006
|
-
If you are adopting pureq in a production service, start with these defaults:
|
|
1007
|
-
|
|
1008
|
-
- Set a request time budget (`defaultTimeout()` or `deadline()`) first.
|
|
1009
|
-
- Add `retry()` with explicit status/method policy.
|
|
1010
|
-
- Add `validation()` for critical response payloads.
|
|
1011
|
-
- Add `circuitBreaker()` for unstable upstreams.
|
|
1012
|
-
- Enable diagnostics hooks (`onRequestStart/onRequestSuccess/onRequestError`).
|
|
1013
|
-
- Prefer `*Result` APIs on boundary layers to avoid unhandled transport errors.
|
|
1014
|
-
- Use `idempotencyKey()` before retrying write operations.
|
|
1015
|
-
|
|
1016
|
-
---
|
|
1017
|
-
|
|
1018
|
-
## API Reference
|
|
1019
|
-
|
|
1020
|
-
### Client
|
|
1021
|
-
|
|
1022
|
-
| Method | Returns | Description |
|
|
1023
|
-
| ------ | ------- | ----------- |
|
|
1024
|
-
| `createClient(options?)` | `PureqClient` | Create a new immutable client |
|
|
1025
|
-
| `client.use(middleware)` | `PureqClient` | Add middleware (returns new client) |
|
|
1026
|
-
| `client.useRequestInterceptor(fn)` | `PureqClient` | Add request interceptor |
|
|
1027
|
-
| `client.useResponseInterceptor(fn)` | `PureqClient` | Add response interceptor |
|
|
1028
|
-
|
|
1029
|
-
### Request Methods
|
|
1030
|
-
|
|
1031
|
-
| Method | Throws? | Returns |
|
|
1032
|
-
| ------ | ------- | ------- |
|
|
1033
|
-
| `client.get(url, opts?)` | Yes | `Promise<HttpResponse>` |
|
|
1034
|
-
| `client.getResult(url, opts?)` | Never | `Promise<Result<HttpResponse, PureqError>>` |
|
|
1035
|
-
| `client.getJson<T>(url, opts?)` | Yes | `Promise<T>` |
|
|
1036
|
-
| `client.getJsonResult<T>(url, opts?)` | Never | `Promise<Result<T, PureqError>>` |
|
|
1037
|
-
| `client.post(url, body?, opts?)` | Yes | `Promise<HttpResponse>` |
|
|
1038
|
-
| `client.postResult(url, body?, opts?)` | Never | `Promise<Result<HttpResponse, PureqError>>` |
|
|
1039
|
-
| `client.postJson<T>(url, body?, opts?)` | Yes | `Promise<T>` |
|
|
1040
|
-
| `client.postJsonResult<T>(url, body?, opts?)` | Never | `Promise<Result<T, PureqError>>` |
|
|
1041
|
-
| `client.put(...)` / `putResult(...)` | — | Same pattern as post |
|
|
1042
|
-
| `client.patch(...)` / `patchResult(...)` | — | Same pattern as post |
|
|
1043
|
-
| `client.delete(...)` / `deleteResult(...)` | — | Same pattern as get |
|
|
1044
|
-
| `client.fetch(url, init?)` | Yes | Familiar `fetch`-like API |
|
|
1045
|
-
| `client.fetchResult(url, init?)` | Never | Result-wrapped fetch-like API |
|
|
1046
|
-
| `client.fetchJson<T>(url, init?)` | Yes | fetch + JSON parse |
|
|
1047
|
-
| `client.request(config)` | Yes | Low-level full config |
|
|
1048
|
-
| `client.requestResult(config)` | Never | Low-level Result variant |
|
|
1049
|
-
| `client.requestJson<T>(config)` | Yes | Low-level + JSON |
|
|
1050
|
-
| `client.requestJsonResult<T>(config)` | Never | Low-level + JSON + Result |
|
|
1051
|
-
|
|
1052
|
-
### Middleware
|
|
1053
|
-
|
|
1054
|
-
| Middleware | Purpose |
|
|
1055
|
-
| --------- | ------- |
|
|
1056
|
-
| `retry(options)` | Exponential backoff, Retry-After, budget |
|
|
1057
|
-
| `authRefresh(options)` | Automatic token refresh (thundering herd prevention) |
|
|
1058
|
-
| `deadline(options)` | Total request budget across retries |
|
|
1059
|
-
| `defaultTimeout(ms)` | Default per-request timeout |
|
|
1060
|
-
| `circuitBreaker(options)` | Fail-fast on repeated failures |
|
|
1061
|
-
| `concurrencyLimit(options)` | Cap in-flight requests |
|
|
1062
|
-
| `dedupe(options?)` | Collapse duplicate concurrent requests |
|
|
1063
|
-
| `hedge(options)` | Duplicate request for tail latency |
|
|
1064
|
-
| `validation(options)` | Schema validation bridge (Zod/Valibot ready) |
|
|
1065
|
-
| `fallback(options)` | Graceful degradation with fallback values |
|
|
1066
|
-
| `httpCache(options)` | In-memory cache with ETag/stale-if-error |
|
|
1067
|
-
| `createOfflineQueue(options?)` | Offline mutation queue with replay |
|
|
1068
|
-
| `idempotencyKey(options?)` | Auto-inject idempotency headers |
|
|
1069
|
-
|
|
1070
|
-
### Available Presets
|
|
1071
|
-
|
|
1072
|
-
| Preset | Best for |
|
|
1073
|
-
| ------ | -------- |
|
|
1074
|
-
| `resilientPreset()` | General-purpose production stack |
|
|
1075
|
-
| `frontendPreset()` | User-facing requests with conservative policy |
|
|
1076
|
-
| `bffPreset()` | BFF with auth propagation and upstream stability |
|
|
1077
|
-
| `backendPreset()` | Service-to-service under sustained load |
|
|
1078
|
-
|
|
1079
|
-
### Observability Exports
|
|
1080
|
-
|
|
1081
|
-
| Export | Purpose |
|
|
1082
|
-
| ------ | ------- |
|
|
1083
|
-
| `createMiddlewareDiagnostics(options)` | Collect metrics and traces |
|
|
1084
|
-
| `createConsoleDiagnosticsExporter()` | Console logging exporter |
|
|
1085
|
-
| `createOpenTelemetryDiagnosticsExporter(meter)` | OTel metrics exporter |
|
|
1086
|
-
| `mapToStandardHttpAttributes(event)` | OTel semantic conventions |
|
|
1087
|
-
| `mapToAwsSemanticConventions(event)` | AWS X-Ray attributes |
|
|
1088
|
-
| `mapToGcpSemanticConventions(event)` | GCP Cloud Trace attributes |
|
|
1089
|
-
| `redactHeaders(headers, options?)` | Redact sensitive headers |
|
|
1090
|
-
| `redactObjectFields(obj, options?)` | Redact fields by pattern |
|
|
1091
|
-
| `redactUrlQueryParams(url, options?)` | Redact URL query params |
|
|
1092
|
-
| `piiRedactionOptions` | Pre-built PII redaction profile |
|
|
1093
|
-
| `authRedactionOptions` | Pre-built auth redaction profile |
|
|
1094
|
-
|
|
1095
|
-
### Adapters and Serializers
|
|
1096
|
-
|
|
1097
|
-
| Export | Purpose |
|
|
1098
|
-
| ------ | ------- |
|
|
1099
|
-
| `fetchAdapter` | Default global fetch adapter |
|
|
1100
|
-
| `createInstrumentedAdapter(base, hooks)` | Adapter with lifecycle hooks |
|
|
1101
|
-
| `jsonBodySerializer` | Default JSON body serializer |
|
|
1102
|
-
| `createFormUrlEncodedSerializer(options?)` | Form URL-encoded serializer |
|
|
1103
|
-
|
|
1104
|
-
---
|
|
1105
|
-
|
|
1106
|
-
## Security
|
|
1107
|
-
|
|
1108
|
-
pureq takes a defense-in-depth approach to transport layer security:
|
|
1109
|
-
|
|
1110
|
-
- **Type-safe Path Templates**: `/users/:id` inherently protects against accidental payload leakage or malformed URL construction compared to manual string interpolation.
|
|
1111
|
-
- **Resource Exhaustion Defense**: Middleware like `deadline()`, `defaultTimeout()`, and `concurrencyLimit()` help mitigate backend overloading and "Slow Loris" style denial-of-service on the client.
|
|
1112
|
-
- **Telemetry Safe-by-Default**: Use built-in diagnostics exports with `redactIndicators`, `redactHeaders`, and `redactObjectFields` to prevent PII and authentication tokens from inadvertently entering server logs or APM dashboards.
|
|
1113
|
-
- **Explicit Serialization**: Defining body serializers restricts accidental serialization of unintended properties compared to ad-hoc `JSON.stringify`.
|
|
1114
|
-
|
|
1115
|
-
*Note: Standard browser-based security constructs like CSRF tokens and CORS remain the responsibility of the underlying `fetch` implementation. pureq stays out of your way and lets standard headers handle web platform security.*
|
|
1116
|
-
|
|
1117
|
-
---
|
|
1118
|
-
|
|
1119
|
-
## Development
|
|
1120
|
-
|
|
1121
|
-
```bash
|
|
1122
|
-
npm run typecheck # type checking
|
|
1123
|
-
npm test # all tests
|
|
1124
|
-
npm run test:unit # lightweight unit tests (heavy load tests excluded)
|
|
1125
|
-
npm run test:stress # baseline stress test
|
|
1126
|
-
npm run test:heavy # heavy load + shock + chaos + invariant-fuzz tests (nightly-recommended)
|
|
1127
|
-
npm run test:nightly # alias of test:heavy
|
|
1128
|
-
npm run test:ci # lightweight CI: unit + integration + contract + stress + typecheck
|
|
1129
|
-
npm run test:ci:full # CI + heavy tests
|
|
1130
|
-
npm run test:browser # browser runtime smoke test
|
|
1131
|
-
npm run test:edge # edge runtime smoke test
|
|
1132
|
-
npm run build # production build
|
|
1133
|
-
npm run benchmark # performance benchmark
|
|
1134
|
-
```
|
|
1135
|
-
|
|
1136
|
-
## Limitations
|
|
1137
|
-
|
|
1138
|
-
pureq is intentionally focused. A few limits are worth knowing:
|
|
1139
|
-
|
|
1140
|
-
- `httpCache()` is in-memory and process-local — not a distributed cache
|
|
1141
|
-
- `hedge()` duplicates requests and should only be used for idempotent reads
|
|
1142
|
-
- `circuitBreaker()` is per-process — for distributed circuit breaking use an external store
|
|
1143
|
-
- JSON helpers are convenience methods, not a replacement for your domain model
|
|
1144
|
-
- Diagnostics exporters are lightweight adapters, not a full telemetry SDK
|
|
1145
|
-
|
|
1146
|
-
These limits are by design. The library is meant to stay small, explicit, and composable.
|
|
1147
|
-
|
|
1148
|
-
## Documentation
|
|
1149
|
-
|
|
1150
|
-
| Document | Content |
|
|
1151
|
-
| -------- | ------- |
|
|
1152
|
-
| [Reliability Primitives](./docs/reliability_primitives.md) | retry, deadline, hedge, circuit breaker |
|
|
1153
|
-
| [Cache & Offline](./docs/cache_and_offline.md) | httpCache, offlineQueue, stale-if-error |
|
|
1154
|
-
| [Observability & Governance](./docs/observability_and_governance.md) | diagnostics, OTel, redaction, guardrails |
|
|
1155
|
-
| [React Query Integration](./docs/integration_react_query.md) | detailed React Query patterns |
|
|
1156
|
-
| [SWR Integration](./docs/integration_swr.md) | detailed SWR patterns |
|
|
1157
|
-
| [BFF & Backend Templates](./docs/templates_bff_backend.md) | per-dependency client patterns |
|
|
1158
|
-
| [Positioning](./docs/positioning_react_query_swr_bff_backend.md) | when to use what |
|
|
1159
|
-
| [Migration Guide](./docs/migration_guide.md) | fetch/axios → pureq step by step |
|
|
1160
|
-
| [Benchmarks](./docs/benchmarks.md) | methodology and baseline numbers |
|
|
1161
|
-
| [Runtime Compatibility](./docs/runtime_compatibility_matrix.md) | supported runtimes |
|
|
1162
|
-
| [Adoption Strategy](./docs/standard_adoption_strategy.md) | org-wide rollout playbook |
|
|
1163
|
-
|
|
1164
86
|
## License
|
|
1165
87
|
|
|
1166
|
-
[
|
|
88
|
+
MIT © [Shihiro](https://github.com/shiro-shihi)
|
package/package.json
CHANGED