@pureq/pureq 1.1.1 → 1.1.3
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 +129 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Functional, immutable, type-safe HTTP transport layer for TypeScript.
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
**pureq** is not another fetch wrapper. It's a **policy-first transport layer** that makes HTTP behavior explicit, composable, and observable
|
|
9
|
+
**pureq** is not another fetch wrapper. It's a **policy-first transport layer** that makes HTTP behavior explicit, composable, and observable across frontend, BFF, backend, and edge runtimes.
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { createClient, retry, circuitBreaker, dedupe } from "pureq";
|
|
@@ -19,7 +19,7 @@ const api = createClient({ baseURL: "https://api.example.com" })
|
|
|
19
19
|
const user = await api.getJson<User>("/users/:id", { params: { id: "42" } });
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Zero runtime dependencies. Works everywhere `fetch` works.
|
|
22
|
+
Zero runtime dependencies in the core package. Works everywhere `fetch` works.
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
@@ -36,55 +36,83 @@ import { FileSystemQueueStorageAdapter } from "pureq/node";
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
> [!NOTE]
|
|
39
|
-
>
|
|
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.
|
|
40
41
|
|
|
41
42
|
## Quick Start
|
|
42
43
|
|
|
43
|
-
###
|
|
44
|
+
### A quicker start than raw fetch
|
|
44
45
|
|
|
45
46
|
```ts
|
|
46
47
|
import { createClient } from "pureq";
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
type User = { id: string; name: string };
|
|
49
50
|
|
|
50
|
-
const
|
|
51
|
-
console.log(response.status); // 200
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
### Typed path parameters
|
|
51
|
+
const client = createClient({ baseURL: "https://api.example.com" });
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
params: { id: "42" },
|
|
53
|
+
// GET + status check + JSON parse + typed path params
|
|
54
|
+
const user = await client.getJson<User>("/users/:userId", {
|
|
55
|
+
params: { userId: "42" },
|
|
60
56
|
});
|
|
61
57
|
|
|
62
|
-
|
|
58
|
+
console.log(user.name);
|
|
63
59
|
```
|
|
64
60
|
|
|
65
|
-
###
|
|
61
|
+
### Type-safe path parameters (Template Literal Types)
|
|
66
62
|
|
|
67
63
|
```ts
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
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" },
|
|
71
74
|
});
|
|
72
75
|
```
|
|
73
76
|
|
|
74
|
-
###
|
|
77
|
+
### Result API to prevent missed error handling
|
|
75
78
|
|
|
76
79
|
```ts
|
|
77
|
-
const result = await client.
|
|
78
|
-
params: {
|
|
80
|
+
const result = await client.getJsonResult<User>("/users/:userId", {
|
|
81
|
+
params: { userId: "42" },
|
|
79
82
|
});
|
|
80
83
|
|
|
81
84
|
if (!result.ok) {
|
|
82
|
-
//
|
|
83
|
-
|
|
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
|
+
}
|
|
84
111
|
return;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
|
-
|
|
114
|
+
// result.data is strongly typed JSON
|
|
115
|
+
renderUser(result.data);
|
|
88
116
|
```
|
|
89
117
|
|
|
90
118
|
Every request method has a `*Result` variant that never throws — transport failures become values you can pattern-match on.
|
|
@@ -243,16 +271,56 @@ const withAuth = withRetry.useRequestInterceptor((req) => ({
|
|
|
243
271
|
|
|
244
272
|
This makes it trivial to share a base client while customizing per-dependency behavior.
|
|
245
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
|
+
|
|
246
298
|
### Middleware: the Onion Model
|
|
247
299
|
|
|
248
300
|
Middleware wraps the entire request lifecycle. Each middleware can intercept the request before it happens, await the result, and post-process the response:
|
|
249
301
|
|
|
250
302
|
```text
|
|
251
|
-
Request
|
|
252
|
-
|
|
253
|
-
|
|
303
|
+
Request
|
|
304
|
+
-> [dedupe]
|
|
305
|
+
-> [retry]
|
|
306
|
+
-> [circuitBreaker]
|
|
307
|
+
-> [adapter/fetch]
|
|
308
|
+
<- [circuitBreaker]
|
|
309
|
+
<- [retry]
|
|
310
|
+
<- [dedupe]
|
|
311
|
+
Response
|
|
254
312
|
```
|
|
255
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
|
+
|
|
256
324
|
Middleware can:
|
|
257
325
|
|
|
258
326
|
- Transform the request before `next()`
|
|
@@ -333,6 +401,23 @@ const client = createClient({ baseURL: "https://api.example.com" })
|
|
|
333
401
|
.use(fallback({ value: { body: "default", status: 200 } as any })); // graceful degradation
|
|
334
402
|
```
|
|
335
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
|
+
|
|
336
421
|
### Retry
|
|
337
422
|
|
|
338
423
|
Full-featured retry with exponential backoff, Retry-After header respect, and retry budget.
|
|
@@ -496,6 +581,7 @@ import { authRefresh } from "pureq";
|
|
|
496
581
|
|
|
497
582
|
client.use(authRefresh({
|
|
498
583
|
refresh: async () => {
|
|
584
|
+
// Multiple 401s will only trigger ONE refresh call.
|
|
499
585
|
const res = await fetch("/api/refresh", { method: "POST" });
|
|
500
586
|
return (await res.json()).token;
|
|
501
587
|
},
|
|
@@ -587,7 +673,7 @@ for (const mw of backendPreset()) backend = backend.use(mw);
|
|
|
587
673
|
| `frontendPreset()` | 5s | 1 | GET/HEAD | 4 failures / 10s cooldown | ✅ |
|
|
588
674
|
| `bffPreset()` | 3s | 2 | GET/HEAD | 5 failures / 20s cooldown | ✅ body-only |
|
|
589
675
|
| `backendPreset()` | 2.5s | 3 | off | 6 failures / 30s cooldown | ✅ body-only |
|
|
590
|
-
| `resilientPreset()` |
|
|
676
|
+
| `resilientPreset()` | none (pair with `defaultTimeout()` if needed) | 2 | GET/HEAD | 5 failures / 30s cooldown | ✅ |
|
|
591
677
|
|
|
592
678
|
All presets are built from the same public middleware. You can inspect and override any parameter.
|
|
593
679
|
|
|
@@ -915,6 +1001,20 @@ More details: [Runtime compatibility matrix](./docs/runtime_compatibility_matrix
|
|
|
915
1001
|
|
|
916
1002
|
---
|
|
917
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
|
+
|
|
918
1018
|
## API Reference
|
|
919
1019
|
|
|
920
1020
|
### Client
|
package/package.json
CHANGED