@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.
Files changed (2) hide show
  1. package/README.md +129 -29
  2. 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 across frontend, BFF, backend, and edge runtimes.
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
- > Using this subpath ensures that Node.js native modules (like `fs` or `path`) are not accidentally bundled into your frontend builds (Webpack/Vite/Esbuild), preventing build-time errors and keeping bundle sizes small.
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
- ### The simplest case
44
+ ### A quicker start than raw fetch
44
45
 
45
46
  ```ts
46
47
  import { createClient } from "pureq";
47
48
 
48
- const client = createClient();
49
+ type User = { id: string; name: string };
49
50
 
50
- const response = await client.get("https://api.example.com/health");
51
- console.log(response.status); // 200
52
- ```
53
-
54
- ### Typed path parameters
51
+ const client = createClient({ baseURL: "https://api.example.com" });
55
52
 
56
- ```ts
57
- // TypeScript ensures you provide { id: string } for :id
58
- const response = await client.get("/users/:id", {
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
- const user = await response.json<{ id: string; name: string }>();
58
+ console.log(user.name);
63
59
  ```
64
60
 
65
- ### JSON helpers (one-liner)
61
+ ### Type-safe path parameters (Template Literal Types)
66
62
 
67
63
  ```ts
68
- // GET + status check + JSON parse in one step
69
- const user = await client.getJson<User>("/users/:id", {
70
- params: { id: "42" },
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
- ### Non-throwing Result API
77
+ ### Result API to prevent missed error handling
75
78
 
76
79
  ```ts
77
- const result = await client.getResult("/users/:id", {
78
- params: { id: "42" },
80
+ const result = await client.getJsonResult<User>("/users/:userId", {
81
+ params: { userId: "42" },
79
82
  });
80
83
 
81
84
  if (!result.ok) {
82
- // kind: human-friendly, code: machine-friendly (same error concept in two formats)
83
- console.error(result.error.kind, result.error.code, result.error.message);
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
- const response = result.data; // HttpResponse
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 → [dedupe] → [retry] → [circuitBreaker] → fetch() → Response
252
-
253
- the "onion" unwinds
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()` | | 2 | All | 5 failures / 30s cooldown | ✅ |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pureq/pureq",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Functional, immutable, and type-safe HTTP client layer with middleware composition.",
5
5
  "main": "./dist/src/index.js",
6
6
  "types": "./dist/src/index.d.ts",