@pureq/pureq 1.0.0
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.md +21 -0
- package/README.md +932 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/benchmark.d.ts +2 -0
- package/dist/scripts/benchmark.d.ts.map +1 -0
- package/dist/scripts/benchmark.js +69 -0
- package/dist/scripts/benchmark.js.map +1 -0
- package/dist/scripts/edge-smoke-entry.d.ts +8 -0
- package/dist/scripts/edge-smoke-entry.d.ts.map +1 -0
- package/dist/scripts/edge-smoke-entry.js +23 -0
- package/dist/scripts/edge-smoke-entry.js.map +1 -0
- package/dist/src/adapters/fetchAdapter.d.ts +3 -0
- package/dist/src/adapters/fetchAdapter.d.ts.map +1 -0
- package/dist/src/adapters/fetchAdapter.js +4 -0
- package/dist/src/adapters/fetchAdapter.js.map +1 -0
- package/dist/src/adapters/instrumentedAdapter.d.ts +21 -0
- package/dist/src/adapters/instrumentedAdapter.d.ts.map +1 -0
- package/dist/src/adapters/instrumentedAdapter.js +25 -0
- package/dist/src/adapters/instrumentedAdapter.js.map +1 -0
- package/dist/src/client/createClient.d.ts +193 -0
- package/dist/src/client/createClient.d.ts.map +1 -0
- package/dist/src/client/createClient.js +310 -0
- package/dist/src/client/createClient.js.map +1 -0
- package/dist/src/executor/execute.d.ts +19 -0
- package/dist/src/executor/execute.d.ts.map +1 -0
- package/dist/src/executor/execute.js +121 -0
- package/dist/src/executor/execute.js.map +1 -0
- package/dist/src/index.d.ts +37 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +36 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/middleware/circuitBreaker.d.ts +77 -0
- package/dist/src/middleware/circuitBreaker.d.ts.map +1 -0
- package/dist/src/middleware/circuitBreaker.js +246 -0
- package/dist/src/middleware/circuitBreaker.js.map +1 -0
- package/dist/src/middleware/circuitBreakerKeys.d.ts +5 -0
- package/dist/src/middleware/circuitBreakerKeys.d.ts.map +1 -0
- package/dist/src/middleware/circuitBreakerKeys.js +30 -0
- package/dist/src/middleware/circuitBreakerKeys.js.map +1 -0
- package/dist/src/middleware/compose.d.ts +9 -0
- package/dist/src/middleware/compose.d.ts.map +1 -0
- package/dist/src/middleware/compose.js +45 -0
- package/dist/src/middleware/compose.js.map +1 -0
- package/dist/src/middleware/concurrencyLimit.d.ts +11 -0
- package/dist/src/middleware/concurrencyLimit.d.ts.map +1 -0
- package/dist/src/middleware/concurrencyLimit.js +126 -0
- package/dist/src/middleware/concurrencyLimit.js.map +1 -0
- package/dist/src/middleware/deadline.d.ts +10 -0
- package/dist/src/middleware/deadline.d.ts.map +1 -0
- package/dist/src/middleware/deadline.js +99 -0
- package/dist/src/middleware/deadline.js.map +1 -0
- package/dist/src/middleware/dedupe.d.ts +13 -0
- package/dist/src/middleware/dedupe.d.ts.map +1 -0
- package/dist/src/middleware/dedupe.js +46 -0
- package/dist/src/middleware/dedupe.js.map +1 -0
- package/dist/src/middleware/defaultTimeout.d.ts +6 -0
- package/dist/src/middleware/defaultTimeout.d.ts.map +1 -0
- package/dist/src/middleware/defaultTimeout.js +23 -0
- package/dist/src/middleware/defaultTimeout.js.map +1 -0
- package/dist/src/middleware/diagnostics.d.ts +28 -0
- package/dist/src/middleware/diagnostics.d.ts.map +1 -0
- package/dist/src/middleware/diagnostics.js +131 -0
- package/dist/src/middleware/diagnostics.js.map +1 -0
- package/dist/src/middleware/diagnosticsExporters.d.ts +27 -0
- package/dist/src/middleware/diagnosticsExporters.d.ts.map +1 -0
- package/dist/src/middleware/diagnosticsExporters.js +45 -0
- package/dist/src/middleware/diagnosticsExporters.js.map +1 -0
- package/dist/src/middleware/hedge.d.ts +17 -0
- package/dist/src/middleware/hedge.d.ts.map +1 -0
- package/dist/src/middleware/hedge.js +125 -0
- package/dist/src/middleware/hedge.js.map +1 -0
- package/dist/src/middleware/httpCache.d.ts +14 -0
- package/dist/src/middleware/httpCache.d.ts.map +1 -0
- package/dist/src/middleware/httpCache.js +126 -0
- package/dist/src/middleware/httpCache.js.map +1 -0
- package/dist/src/middleware/idempotencyKey.d.ts +12 -0
- package/dist/src/middleware/idempotencyKey.d.ts.map +1 -0
- package/dist/src/middleware/idempotencyKey.js +33 -0
- package/dist/src/middleware/idempotencyKey.js.map +1 -0
- package/dist/src/middleware/offlineQueue.d.ts +78 -0
- package/dist/src/middleware/offlineQueue.d.ts.map +1 -0
- package/dist/src/middleware/offlineQueue.js +189 -0
- package/dist/src/middleware/offlineQueue.js.map +1 -0
- package/dist/src/middleware/presets.d.ts +31 -0
- package/dist/src/middleware/presets.d.ts.map +1 -0
- package/dist/src/middleware/presets.js +122 -0
- package/dist/src/middleware/presets.js.map +1 -0
- package/dist/src/middleware/retry.d.ts +27 -0
- package/dist/src/middleware/retry.d.ts.map +1 -0
- package/dist/src/middleware/retry.js +189 -0
- package/dist/src/middleware/retry.js.map +1 -0
- package/dist/src/middleware/stalePolicy.d.ts +14 -0
- package/dist/src/middleware/stalePolicy.d.ts.map +1 -0
- package/dist/src/middleware/stalePolicy.js +13 -0
- package/dist/src/middleware/stalePolicy.js.map +1 -0
- package/dist/src/observability/otelMapping.d.ts +13 -0
- package/dist/src/observability/otelMapping.d.ts.map +1 -0
- package/dist/src/observability/otelMapping.js +31 -0
- package/dist/src/observability/otelMapping.js.map +1 -0
- package/dist/src/observability/otelProfiles.d.ts +16 -0
- package/dist/src/observability/otelProfiles.d.ts.map +1 -0
- package/dist/src/observability/otelProfiles.js +66 -0
- package/dist/src/observability/otelProfiles.js.map +1 -0
- package/dist/src/observability/redaction.d.ts +39 -0
- package/dist/src/observability/redaction.d.ts.map +1 -0
- package/dist/src/observability/redaction.js +112 -0
- package/dist/src/observability/redaction.js.map +1 -0
- package/dist/src/policy/guardrails.d.ts +14 -0
- package/dist/src/policy/guardrails.d.ts.map +1 -0
- package/dist/src/policy/guardrails.js +36 -0
- package/dist/src/policy/guardrails.js.map +1 -0
- package/dist/src/response/response.d.ts +58 -0
- package/dist/src/response/response.d.ts.map +1 -0
- package/dist/src/response/response.js +91 -0
- package/dist/src/response/response.js.map +1 -0
- package/dist/src/serializers/formUrlEncodedSerializer.d.ts +6 -0
- package/dist/src/serializers/formUrlEncodedSerializer.d.ts.map +1 -0
- package/dist/src/serializers/formUrlEncodedSerializer.js +38 -0
- package/dist/src/serializers/formUrlEncodedSerializer.js.map +1 -0
- package/dist/src/serializers/jsonBodySerializer.d.ts +3 -0
- package/dist/src/serializers/jsonBodySerializer.d.ts.map +1 -0
- package/dist/src/serializers/jsonBodySerializer.js +24 -0
- package/dist/src/serializers/jsonBodySerializer.js.map +1 -0
- package/dist/src/types/events.d.ts +49 -0
- package/dist/src/types/events.d.ts.map +1 -0
- package/dist/src/types/events.js +2 -0
- package/dist/src/types/events.js.map +1 -0
- package/dist/src/types/http.d.ts +78 -0
- package/dist/src/types/http.d.ts.map +1 -0
- package/dist/src/types/http.js +2 -0
- package/dist/src/types/http.js.map +1 -0
- package/dist/src/types/internal.d.ts +20 -0
- package/dist/src/types/internal.d.ts.map +1 -0
- package/dist/src/types/internal.js +2 -0
- package/dist/src/types/internal.js.map +1 -0
- package/dist/src/types/result.d.ts +32 -0
- package/dist/src/types/result.d.ts.map +1 -0
- package/dist/src/types/result.js +66 -0
- package/dist/src/types/result.js.map +1 -0
- package/dist/src/utils/crypto.d.ts +8 -0
- package/dist/src/utils/crypto.d.ts.map +1 -0
- package/dist/src/utils/crypto.js +21 -0
- package/dist/src/utils/crypto.js.map +1 -0
- package/dist/src/utils/policyTrace.d.ts +17 -0
- package/dist/src/utils/policyTrace.d.ts.map +1 -0
- package/dist/src/utils/policyTrace.js +34 -0
- package/dist/src/utils/policyTrace.js.map +1 -0
- package/dist/src/utils/stableKey.d.ts +17 -0
- package/dist/src/utils/stableKey.d.ts.map +1 -0
- package/dist/src/utils/stableKey.js +41 -0
- package/dist/src/utils/stableKey.js.map +1 -0
- package/dist/src/utils/url.d.ts +22 -0
- package/dist/src/utils/url.d.ts.map +1 -0
- package/dist/src/utils/url.js +2 -0
- package/dist/src/utils/url.js.map +1 -0
- package/dist/tests/adapter-serializer.test.d.ts +2 -0
- package/dist/tests/adapter-serializer.test.d.ts.map +1 -0
- package/dist/tests/adapter-serializer.test.js +59 -0
- package/dist/tests/adapter-serializer.test.js.map +1 -0
- package/dist/tests/browser-runtime.smoke.test.d.ts +2 -0
- package/dist/tests/browser-runtime.smoke.test.d.ts.map +1 -0
- package/dist/tests/browser-runtime.smoke.test.js +17 -0
- package/dist/tests/browser-runtime.smoke.test.js.map +1 -0
- package/dist/tests/circuit-breaker.test.d.ts +2 -0
- package/dist/tests/circuit-breaker.test.d.ts.map +1 -0
- package/dist/tests/circuit-breaker.test.js +184 -0
- package/dist/tests/circuit-breaker.test.js.map +1 -0
- package/dist/tests/client.integration.d.ts +2 -0
- package/dist/tests/client.integration.d.ts.map +1 -0
- package/dist/tests/client.integration.js +44 -0
- package/dist/tests/client.integration.js.map +1 -0
- package/dist/tests/client.integration.test.d.ts +2 -0
- package/dist/tests/client.integration.test.d.ts.map +1 -0
- package/dist/tests/client.integration.test.js +63 -0
- package/dist/tests/client.integration.test.js.map +1 -0
- package/dist/tests/compose.test.d.ts +2 -0
- package/dist/tests/compose.test.d.ts.map +1 -0
- package/dist/tests/compose.test.js +24 -0
- package/dist/tests/compose.test.js.map +1 -0
- package/dist/tests/concurrency-limit.test.d.ts +2 -0
- package/dist/tests/concurrency-limit.test.d.ts.map +1 -0
- package/dist/tests/concurrency-limit.test.js +87 -0
- package/dist/tests/concurrency-limit.test.js.map +1 -0
- package/dist/tests/deadline-middleware.test.d.ts +2 -0
- package/dist/tests/deadline-middleware.test.d.ts.map +1 -0
- package/dist/tests/deadline-middleware.test.js +38 -0
- package/dist/tests/deadline-middleware.test.js.map +1 -0
- package/dist/tests/dedupe-middleware.test.d.ts +2 -0
- package/dist/tests/dedupe-middleware.test.d.ts.map +1 -0
- package/dist/tests/dedupe-middleware.test.js +56 -0
- package/dist/tests/dedupe-middleware.test.js.map +1 -0
- package/dist/tests/default-timeout.test.d.ts +2 -0
- package/dist/tests/default-timeout.test.d.ts.map +1 -0
- package/dist/tests/default-timeout.test.js +28 -0
- package/dist/tests/default-timeout.test.js.map +1 -0
- package/dist/tests/diagnostics-exporters.test.d.ts +2 -0
- package/dist/tests/diagnostics-exporters.test.d.ts.map +1 -0
- package/dist/tests/diagnostics-exporters.test.js +41 -0
- package/dist/tests/diagnostics-exporters.test.js.map +1 -0
- package/dist/tests/diagnostics.test.d.ts +2 -0
- package/dist/tests/diagnostics.test.d.ts.map +1 -0
- package/dist/tests/diagnostics.test.js +38 -0
- package/dist/tests/diagnostics.test.js.map +1 -0
- package/dist/tests/error-metadata.test.d.ts +2 -0
- package/dist/tests/error-metadata.test.d.ts.map +1 -0
- package/dist/tests/error-metadata.test.js +23 -0
- package/dist/tests/error-metadata.test.js.map +1 -0
- package/dist/tests/execute-timeout.test.d.ts +2 -0
- package/dist/tests/execute-timeout.test.d.ts.map +1 -0
- package/dist/tests/execute-timeout.test.js +30 -0
- package/dist/tests/execute-timeout.test.js.map +1 -0
- package/dist/tests/form-serializer.test.d.ts +2 -0
- package/dist/tests/form-serializer.test.d.ts.map +1 -0
- package/dist/tests/form-serializer.test.js +16 -0
- package/dist/tests/form-serializer.test.js.map +1 -0
- package/dist/tests/hedge.test.d.ts +2 -0
- package/dist/tests/hedge.test.d.ts.map +1 -0
- package/dist/tests/hedge.test.js +45 -0
- package/dist/tests/hedge.test.js.map +1 -0
- package/dist/tests/http-cache.test.d.ts +2 -0
- package/dist/tests/http-cache.test.d.ts.map +1 -0
- package/dist/tests/http-cache.test.js +60 -0
- package/dist/tests/http-cache.test.js.map +1 -0
- package/dist/tests/idempotency-key.test.d.ts +2 -0
- package/dist/tests/idempotency-key.test.d.ts.map +1 -0
- package/dist/tests/idempotency-key.test.js +31 -0
- package/dist/tests/idempotency-key.test.js.map +1 -0
- package/dist/tests/instrumented-adapter.test.d.ts +2 -0
- package/dist/tests/instrumented-adapter.test.d.ts.map +1 -0
- package/dist/tests/instrumented-adapter.test.js +33 -0
- package/dist/tests/instrumented-adapter.test.js.map +1 -0
- package/dist/tests/interceptor-order.test.d.ts +2 -0
- package/dist/tests/interceptor-order.test.d.ts.map +1 -0
- package/dist/tests/interceptor-order.test.js +38 -0
- package/dist/tests/interceptor-order.test.js.map +1 -0
- package/dist/tests/json-helper.test.d.ts +2 -0
- package/dist/tests/json-helper.test.d.ts.map +1 -0
- package/dist/tests/json-helper.test.js +42 -0
- package/dist/tests/json-helper.test.js.map +1 -0
- package/dist/tests/observability.test.d.ts +2 -0
- package/dist/tests/observability.test.d.ts.map +1 -0
- package/dist/tests/observability.test.js +59 -0
- package/dist/tests/observability.test.js.map +1 -0
- package/dist/tests/offline-queue.test.d.ts +2 -0
- package/dist/tests/offline-queue.test.d.ts.map +1 -0
- package/dist/tests/offline-queue.test.js +35 -0
- package/dist/tests/offline-queue.test.js.map +1 -0
- package/dist/tests/otel-mapping.test.d.ts +2 -0
- package/dist/tests/otel-mapping.test.d.ts.map +1 -0
- package/dist/tests/otel-mapping.test.js +20 -0
- package/dist/tests/otel-mapping.test.js.map +1 -0
- package/dist/tests/policy-guardrails.test.d.ts +2 -0
- package/dist/tests/policy-guardrails.test.d.ts.map +1 -0
- package/dist/tests/policy-guardrails.test.js +25 -0
- package/dist/tests/policy-guardrails.test.js.map +1 -0
- package/dist/tests/presets.test.d.ts +2 -0
- package/dist/tests/presets.test.d.ts.map +1 -0
- package/dist/tests/presets.test.js +41 -0
- package/dist/tests/presets.test.js.map +1 -0
- package/dist/tests/public-api.contract.d.ts +2 -0
- package/dist/tests/public-api.contract.d.ts.map +1 -0
- package/dist/tests/public-api.contract.js +14 -0
- package/dist/tests/public-api.contract.js.map +1 -0
- package/dist/tests/public-api.contract.test.d.ts +2 -0
- package/dist/tests/public-api.contract.test.d.ts.map +1 -0
- package/dist/tests/public-api.contract.test.js +25 -0
- package/dist/tests/public-api.contract.test.js.map +1 -0
- package/dist/tests/redaction.test.d.ts +2 -0
- package/dist/tests/redaction.test.d.ts.map +1 -0
- package/dist/tests/redaction.test.js +23 -0
- package/dist/tests/redaction.test.js.map +1 -0
- package/dist/tests/retry-after-budget.test.d.ts +2 -0
- package/dist/tests/retry-after-budget.test.d.ts.map +1 -0
- package/dist/tests/retry-after-budget.test.js +72 -0
- package/dist/tests/retry-after-budget.test.js.map +1 -0
- package/dist/tests/retry-policy-trace.test.d.ts +2 -0
- package/dist/tests/retry-policy-trace.test.d.ts.map +1 -0
- package/dist/tests/retry-policy-trace.test.js +35 -0
- package/dist/tests/retry-policy-trace.test.js.map +1 -0
- package/dist/tests/retry-policy.test.d.ts +2 -0
- package/dist/tests/retry-policy.test.d.ts.map +1 -0
- package/dist/tests/retry-policy.test.js +51 -0
- package/dist/tests/retry-policy.test.js.map +1 -0
- package/dist/tests/retry.stress.d.ts +2 -0
- package/dist/tests/retry.stress.d.ts.map +1 -0
- package/dist/tests/retry.stress.js +21 -0
- package/dist/tests/retry.stress.js.map +1 -0
- package/dist/tests/retry.stress.test.d.ts +2 -0
- package/dist/tests/retry.stress.test.d.ts.map +1 -0
- package/dist/tests/retry.stress.test.js +21 -0
- package/dist/tests/retry.stress.test.js.map +1 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
# pureq
|
|
2
|
+
|
|
3
|
+
Functional, immutable, type-safe HTTP transport layer for TypeScript.
|
|
4
|
+
|
|
5
|
+
[Quick Start](#quick-start) · [Why pureq](#why-pureq) · [Middleware](#reliability-middleware) · [React Query](#react-query--swr-integration) · [BFF / Backend](#bff--backend-patterns) · [API Reference](#api-reference)
|
|
6
|
+
|
|
7
|
+
---
|
|
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.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createClient, retry, circuitBreaker, dedupe } from "pureq";
|
|
13
|
+
|
|
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. Works everywhere `fetch` works.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @pureq/pureq
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### The simplest case
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createClient } from "pureq";
|
|
36
|
+
|
|
37
|
+
const client = createClient();
|
|
38
|
+
|
|
39
|
+
const response = await client.get("https://api.example.com/health");
|
|
40
|
+
console.log(response.status); // 200
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Typed path parameters
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// TypeScript ensures you provide { id: string } for :id
|
|
47
|
+
const response = await client.get("/users/:id", {
|
|
48
|
+
params: { id: "42" },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const user = await response.json<{ id: string; name: string }>();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### JSON helpers (one-liner)
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// GET + status check + JSON parse in one step
|
|
58
|
+
const user = await client.getJson<User>("/users/:id", {
|
|
59
|
+
params: { id: "42" },
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Non-throwing Result API
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const result = await client.getResult("/users/:id", {
|
|
67
|
+
params: { id: "42" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
// result.error.kind: "network" | "timeout" | "aborted" | "http" | "circuit-open" | "unknown"
|
|
72
|
+
console.error(result.error.kind, result.error.message);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const response = result.data; // HttpResponse
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every request method has a `*Result` variant that never throws — transport failures become values you can pattern-match on.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Why pureq
|
|
84
|
+
|
|
85
|
+
### The problem with raw `fetch`
|
|
86
|
+
|
|
87
|
+
`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:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// This is what real-world fetch code looks like
|
|
91
|
+
async function fetchUser(id: string) {
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(`/api/users/${id}`, {
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
|
|
101
|
+
if (response.status === 429) {
|
|
102
|
+
// retry? how many times? what delay?
|
|
103
|
+
}
|
|
104
|
+
if (response.status >= 500) {
|
|
105
|
+
// retry? circuit break? log?
|
|
106
|
+
}
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`HTTP ${response.status}`);
|
|
109
|
+
}
|
|
110
|
+
return await response.json();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
clearTimeout(timeout);
|
|
113
|
+
// is it a timeout? network error? abort? how do we tell?
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Every endpoint handler re-decides retry, timeout, error shape, and observability. There's no consistency, no composition, and no governance.
|
|
120
|
+
|
|
121
|
+
**pureq replaces that with:**
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const api = createClient({ baseURL: "/api" })
|
|
125
|
+
.use(retry({ maxRetries: 2, delay: 200, retryOnStatus: [429, 500, 502, 503] }))
|
|
126
|
+
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }));
|
|
127
|
+
|
|
128
|
+
const user = await api.getJson<User>("/users/:id", {
|
|
129
|
+
params: { id },
|
|
130
|
+
timeout: 5000,
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Policy is declared once, applied everywhere, and enforced by the type system.
|
|
135
|
+
|
|
136
|
+
### How pureq compares to axios
|
|
137
|
+
|
|
138
|
+
axios is familiar and battle-tested. But its mutability model makes transport behavior hard to reason about at scale:
|
|
139
|
+
|
|
140
|
+
| Concern | **axios** | **pureq** |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| Client model | Mutable instances | Immutable — `.use()` returns new client |
|
|
143
|
+
| Retry/Circuit breaker | External packages (axios-retry, etc.) | Built-in middleware |
|
|
144
|
+
| Error model | Throws by default, boolean flags | `Result<T, E>` pattern — no exceptions |
|
|
145
|
+
| Path params | String interpolation | Type-safe `:param` templates |
|
|
146
|
+
| Middleware model | Interceptors (mutate config) | Onion middleware (compose behavior) |
|
|
147
|
+
| Policy guardrails | None | Validates invalid combinations at startup |
|
|
148
|
+
| Observability | Interceptor-based logging | Structured event hooks + OTel export |
|
|
149
|
+
| Bundle | ~14 KB gzipped + adapters | Zero-dependency, tree-shakeable |
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
### pureq vs React Query / SWR
|
|
154
|
+
|
|
155
|
+
pureq does **not** replace React Query or SWR. They solve different problems:
|
|
156
|
+
|
|
157
|
+
| Concern | **React Query / SWR** | **pureq** |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| Cache lifecycle | ✅ stale-while-revalidate, GC, refetch | ❌ not a UI cache |
|
|
160
|
+
| Query keys | ✅ declarative caching | ❌ |
|
|
161
|
+
| Suspense integration | ✅ | ❌ |
|
|
162
|
+
| UI state (loading/error) | ✅ | ❌ |
|
|
163
|
+
| Retry + backoff | ⚠️ basic | ✅ full control |
|
|
164
|
+
| Circuit breaker | ❌ | ✅ |
|
|
165
|
+
| Deadline propagation | ❌ | ✅ |
|
|
166
|
+
| Request dedup | ⚠️ by query key | ✅ by request signature |
|
|
167
|
+
| Concurrency limits | ❌ | ✅ |
|
|
168
|
+
| Hedged requests | ❌ | ✅ |
|
|
169
|
+
| Offline queue | ❌ | ✅ |
|
|
170
|
+
| Request observability | ❌ | ✅ OTel-ready |
|
|
171
|
+
| Idempotency keys | ❌ | ✅ |
|
|
172
|
+
| Multi-runtime | ⚠️ React-only | ✅ Any JS runtime |
|
|
173
|
+
|
|
174
|
+
**They compose perfectly together** — pureq is the transport layer *underneath* React Query:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { useQuery } from "@tanstack/react-query";
|
|
178
|
+
import { createClient, retry, circuitBreaker } from "pureq";
|
|
179
|
+
|
|
180
|
+
const api = createClient({ baseURL: "https://api.example.com" })
|
|
181
|
+
.use(retry({ maxRetries: 2, delay: 200 }))
|
|
182
|
+
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }));
|
|
183
|
+
|
|
184
|
+
function useUser(id: string) {
|
|
185
|
+
return useQuery({
|
|
186
|
+
queryKey: ["user", id],
|
|
187
|
+
queryFn: () => api.getJson<User>("/users/:id", { params: { id } }),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
React Query handles: cache lifecycle, stale-while-revalidate, refetching, and suspense.
|
|
193
|
+
pureq handles: retry strategy, circuit breaking, timeouts, dedup, and telemetry.
|
|
194
|
+
|
|
195
|
+
Clean separation. No overlap.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Core Concepts
|
|
200
|
+
|
|
201
|
+
### Immutable client composition
|
|
202
|
+
|
|
203
|
+
Every call to `.use()`, `.useRequestInterceptor()`, or `.useResponseInterceptor()` returns a **new** client instance. The original is never mutated.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const base = createClient({ baseURL: "https://api.example.com" });
|
|
207
|
+
const withRetry = base.use(retry({ maxRetries: 2, delay: 200 }));
|
|
208
|
+
const withAuth = withRetry.useRequestInterceptor((req) => ({
|
|
209
|
+
...req,
|
|
210
|
+
headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
// base, withRetry, and withAuth are three separate clients
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
This makes it trivial to share a base client while customizing per-dependency behavior.
|
|
217
|
+
|
|
218
|
+
### Middleware: the Onion Model
|
|
219
|
+
|
|
220
|
+
Middleware wraps the entire request lifecycle. Each middleware can intercept the request before it happens, await the result, and post-process the response:
|
|
221
|
+
|
|
222
|
+
```text
|
|
223
|
+
Request → [dedupe] → [retry] → [circuitBreaker] → fetch() → Response
|
|
224
|
+
↑
|
|
225
|
+
the "onion" unwinds
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Middleware can:
|
|
229
|
+
|
|
230
|
+
- Transform the request before `next()`
|
|
231
|
+
- Decide whether to call `next()` at all (e.g., cache hit, circuit open)
|
|
232
|
+
- Retry `next()` on failure (e.g., retry middleware)
|
|
233
|
+
- Transform the response after `next()`
|
|
234
|
+
|
|
235
|
+
### Interceptors vs Middleware
|
|
236
|
+
|
|
237
|
+
pureq distinctly separates lifecycle control from simple data transformation:
|
|
238
|
+
|
|
239
|
+
- **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.
|
|
240
|
+
- **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.
|
|
241
|
+
|
|
242
|
+
### Type-safe path parameters
|
|
243
|
+
|
|
244
|
+
Route templates like `/users/:userId/posts/:postId` are type-checked at compile time:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// ✅ Compiles — params match the URL template
|
|
248
|
+
await client.get("/users/:userId/posts/:postId", {
|
|
249
|
+
params: { userId: "1", postId: "42" },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ❌ TypeScript error — missing 'postId'
|
|
253
|
+
await client.get("/users/:userId/posts/:postId", {
|
|
254
|
+
params: { userId: "1" },
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Result-based error handling
|
|
259
|
+
|
|
260
|
+
Instead of `try/catch` everywhere, use the `*Result` variants for explicit error handling:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const result = await client.postResult("/orders", orderData);
|
|
264
|
+
|
|
265
|
+
if (!result.ok) {
|
|
266
|
+
switch (result.error.kind) {
|
|
267
|
+
case "network":
|
|
268
|
+
showOfflineNotice();
|
|
269
|
+
break;
|
|
270
|
+
case "timeout":
|
|
271
|
+
showRetryPrompt();
|
|
272
|
+
break;
|
|
273
|
+
case "circuit-open":
|
|
274
|
+
showDegradedMode();
|
|
275
|
+
break;
|
|
276
|
+
default:
|
|
277
|
+
logError(result.error);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// result.data is HttpResponse
|
|
283
|
+
const order = await result.data.json<Order>();
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Streams and Binary Data
|
|
287
|
+
|
|
288
|
+
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.
|
|
289
|
+
|
|
290
|
+
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.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Reliability Middleware
|
|
295
|
+
|
|
296
|
+
All middleware is composable. Stack them in the order you want:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
const client = createClient({ baseURL: "https://api.example.com" })
|
|
300
|
+
.use(dedupe()) // collapse duplicate in-flight GETs
|
|
301
|
+
.use(httpCache({ ttlMs: 10_000, maxEntries: 200 })) // in-memory cache with LRU eviction
|
|
302
|
+
.use(retry({ maxRetries: 3, delay: 200, backoff: true })) // exponential backoff
|
|
303
|
+
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 }));
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Retry
|
|
307
|
+
|
|
308
|
+
Full-featured retry with exponential backoff, Retry-After header respect, and retry budget.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { retry } from "pureq";
|
|
312
|
+
|
|
313
|
+
client.use(retry({
|
|
314
|
+
maxRetries: 3,
|
|
315
|
+
delay: 200,
|
|
316
|
+
backoff: true,
|
|
317
|
+
maxDelay: 5_000,
|
|
318
|
+
retryBudgetMs: 2000, // total time cap across all retries
|
|
319
|
+
retryOnStatus: [429, 500, 502, 503, 504],
|
|
320
|
+
respectRetryAfter: true,
|
|
321
|
+
onRetry: ({ attempt, error }) => console.warn(`Retry #${attempt}`, error),
|
|
322
|
+
}));
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Deadline Propagation
|
|
326
|
+
|
|
327
|
+
Enforces a total request budget across all retry attempts — different from a per-request timeout.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { deadline, retry } from "pureq";
|
|
331
|
+
|
|
332
|
+
const client = createClient()
|
|
333
|
+
.use(deadline({ defaultTimeoutMs: 1500 })) // 1.5s total, no matter how many retries
|
|
334
|
+
.use(retry({ maxRetries: 3, delay: 200 }));
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Circuit Breaker
|
|
338
|
+
|
|
339
|
+
Stops sending requests to a failing dependency. Automatically probes for recovery.
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
import { circuitBreaker } from "pureq";
|
|
343
|
+
|
|
344
|
+
client.use(circuitBreaker({
|
|
345
|
+
failureThreshold: 5, // open after 5 consecutive failures
|
|
346
|
+
successThreshold: 2, // close after 2 successes in half-open
|
|
347
|
+
cooldownMs: 30_000, // probe again after 30s
|
|
348
|
+
}));
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Concurrency Limit
|
|
352
|
+
|
|
353
|
+
Caps in-flight requests globally or by key to protect backend resources.
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
import { concurrencyLimit } from "pureq";
|
|
357
|
+
|
|
358
|
+
client.use(concurrencyLimit({
|
|
359
|
+
maxConcurrent: 20,
|
|
360
|
+
keyBuilder: (req) => new URL(req.url).hostname,
|
|
361
|
+
}));
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Hedged Requests
|
|
365
|
+
|
|
366
|
+
Issues a duplicate request after a short delay for tail-latency-sensitive reads. The first response wins; the loser is aborted.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { hedge } from "pureq";
|
|
370
|
+
|
|
371
|
+
client.use(hedge({
|
|
372
|
+
hedgeAfterMs: 80,
|
|
373
|
+
methods: ["GET"],
|
|
374
|
+
}));
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Request Deduplication
|
|
378
|
+
|
|
379
|
+
Collapses concurrent duplicate GET requests into a single in-flight call.
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
import { dedupe } from "pureq";
|
|
383
|
+
|
|
384
|
+
client.use(dedupe({
|
|
385
|
+
methods: ["GET", "HEAD"],
|
|
386
|
+
}));
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### HTTP Cache
|
|
390
|
+
|
|
391
|
+
In-memory cache with ETag revalidation and stale-if-error fallback.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import { httpCache } from "pureq";
|
|
395
|
+
|
|
396
|
+
client.use(httpCache({
|
|
397
|
+
ttlMs: 10_000,
|
|
398
|
+
staleIfErrorMs: 60_000,
|
|
399
|
+
maxEntries: 500, // LRU eviction when full
|
|
400
|
+
}));
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Offline Queue
|
|
404
|
+
|
|
405
|
+
Queues mutation requests when offline and replays them when connectivity restores.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
import { createOfflineQueue, idempotencyKey } from "pureq";
|
|
409
|
+
|
|
410
|
+
const queue = createOfflineQueue({
|
|
411
|
+
methods: ["POST", "PUT", "PATCH"],
|
|
412
|
+
maxQueueSize: 100,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const client = createClient()
|
|
416
|
+
.use(idempotencyKey()) // strongly recommended with offlineQueue
|
|
417
|
+
.use(queue.middleware);
|
|
418
|
+
|
|
419
|
+
// Later, when back online:
|
|
420
|
+
await queue.flush(
|
|
421
|
+
(req) => client.post(req.url, req.body),
|
|
422
|
+
{ concurrency: 3 } // parallel replay
|
|
423
|
+
);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Idempotency Keys
|
|
427
|
+
|
|
428
|
+
Automatically injects idempotency key headers for mutation requests.
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
import { idempotencyKey } from "pureq";
|
|
432
|
+
|
|
433
|
+
client.use(idempotencyKey({
|
|
434
|
+
headerName: "Idempotency-Key", // default
|
|
435
|
+
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
436
|
+
}));
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Policy Guardrails
|
|
440
|
+
|
|
441
|
+
pureq validates your middleware stack at client creation time and rejects invalid combinations:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
// ❌ Throws: "pureq: multiple retry policies are not allowed in one client"
|
|
445
|
+
createClient()
|
|
446
|
+
.use(retry({ maxRetries: 2, delay: 200 }))
|
|
447
|
+
.use(retry({ maxRetries: 3, delay: 300 }));
|
|
448
|
+
|
|
449
|
+
// ❌ Throws: "pureq: use deadline or defaultTimeout, not both"
|
|
450
|
+
createClient()
|
|
451
|
+
.use(deadline({ defaultTimeoutMs: 1500 }))
|
|
452
|
+
.use(defaultTimeout(3000));
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Presets
|
|
458
|
+
|
|
459
|
+
For teams that want production-ready defaults without configuring each middleware:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { createClient, frontendPreset, bffPreset, backendPreset } from "pureq";
|
|
463
|
+
|
|
464
|
+
// Frontend: conservative retries, short timeout, dedup for GETs
|
|
465
|
+
let frontend = createClient();
|
|
466
|
+
for (const mw of frontendPreset()) frontend = frontend.use(mw);
|
|
467
|
+
|
|
468
|
+
// BFF: balanced latency vs stability, idempotency for mutations
|
|
469
|
+
let bff = createClient();
|
|
470
|
+
for (const mw of bffPreset()) bff = bff.use(mw);
|
|
471
|
+
|
|
472
|
+
// Backend: aggressive retries, circuit breaker, no dedup
|
|
473
|
+
let backend = createClient();
|
|
474
|
+
for (const mw of backendPreset()) backend = backend.use(mw);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
| Preset | Timeout | Retries | Dedup | Circuit Breaker | Idempotency |
|
|
478
|
+
| ------ | ------- | ------- | ----- | --------------- | ----------- |
|
|
479
|
+
| `frontendPreset()` | 5s | 1 | GET/HEAD | 4 failures / 10s cooldown | ✅ |
|
|
480
|
+
| `bffPreset()` | 3s | 2 | GET/HEAD | 5 failures / 20s cooldown | ✅ body-only |
|
|
481
|
+
| `backendPreset()` | 2.5s | 3 | off | 6 failures / 30s cooldown | ✅ body-only |
|
|
482
|
+
| `resilientPreset()` | — | 2 | All | 5 failures / 30s cooldown | ✅ |
|
|
483
|
+
|
|
484
|
+
All presets are built from the same public middleware. You can inspect and override any parameter.
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## Observability
|
|
489
|
+
|
|
490
|
+
### Client lifecycle hooks
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
const client = createClient({
|
|
494
|
+
hooks: {
|
|
495
|
+
onRequestStart: (event) => {
|
|
496
|
+
console.log(`→ ${event.method} ${event.url} [${event.requestId}]`);
|
|
497
|
+
},
|
|
498
|
+
onRequestSuccess: (event) => {
|
|
499
|
+
console.log(`✓ ${event.status} in ${event.latencyMs}ms [retries: ${event.retryCount}]`);
|
|
500
|
+
},
|
|
501
|
+
onRequestError: (event) => {
|
|
502
|
+
console.error(`✗ ${event.errorKind}: ${event.error.message}`);
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Diagnostics middleware
|
|
509
|
+
|
|
510
|
+
Collect per-request performance metrics and policy traces:
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
import { createMiddlewareDiagnostics, createConsoleDiagnosticsExporter } from "pureq";
|
|
514
|
+
|
|
515
|
+
const diagnostics = createMiddlewareDiagnostics({
|
|
516
|
+
onEvent: createConsoleDiagnosticsExporter().export,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const client = createClient().use(diagnostics.middleware);
|
|
520
|
+
|
|
521
|
+
// Inspect metrics
|
|
522
|
+
const snap = diagnostics.snapshot();
|
|
523
|
+
console.log(snap.p50, snap.p95, snap.total, snap.success, snap.failed);
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### OpenTelemetry integration
|
|
527
|
+
|
|
528
|
+
Map transport events to OTel-compatible attributes:
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import {
|
|
532
|
+
mapToStandardHttpAttributes,
|
|
533
|
+
mapToAwsSemanticConventions,
|
|
534
|
+
mapToGcpSemanticConventions,
|
|
535
|
+
} from "pureq";
|
|
536
|
+
|
|
537
|
+
// Standard OTel HTTP semantic conventions
|
|
538
|
+
const attrs = mapToStandardHttpAttributes(event);
|
|
539
|
+
// { "http.method": "GET", "http.url": "...", "http.status_code": 200, ... }
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Redaction for safe telemetry
|
|
543
|
+
|
|
544
|
+
Built-in redaction profiles prevent sensitive data from leaking into logs and telemetry:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
import {
|
|
548
|
+
redactHeaders,
|
|
549
|
+
redactObjectFields,
|
|
550
|
+
redactUrlQueryParams,
|
|
551
|
+
piiRedactionOptions,
|
|
552
|
+
authRedactionOptions,
|
|
553
|
+
} from "pureq";
|
|
554
|
+
|
|
555
|
+
redactHeaders(headers);
|
|
556
|
+
// Authorization: "[REDACTED]", Cookie: "[REDACTED]", ...
|
|
557
|
+
|
|
558
|
+
redactUrlQueryParams("https://api.example.com/v1?token=secret123&page=1");
|
|
559
|
+
// "https://api.example.com/v1?token=[REDACTED]&page=1"
|
|
560
|
+
|
|
561
|
+
redactObjectFields(body, piiRedactionOptions);
|
|
562
|
+
// { email: "[REDACTED]", phone: "[REDACTED]", name: "Alice" }
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
## React Query / SWR Integration
|
|
568
|
+
|
|
569
|
+
### React Query
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
573
|
+
import { createClient, retry } from "pureq";
|
|
574
|
+
|
|
575
|
+
const api = createClient({ baseURL: "https://api.example.com" })
|
|
576
|
+
.use(retry({ maxRetries: 2, delay: 200 }));
|
|
577
|
+
|
|
578
|
+
// Queries
|
|
579
|
+
function useUser(id: string) {
|
|
580
|
+
return useQuery({
|
|
581
|
+
queryKey: ["user", id],
|
|
582
|
+
queryFn: () => api.getJson<User>("/users/:id", { params: { id } }),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Mutations
|
|
587
|
+
function useCreatePost() {
|
|
588
|
+
return useMutation({
|
|
589
|
+
mutationFn: (data: CreatePostInput) =>
|
|
590
|
+
api.postJson<Post>("/posts", data),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### SWR
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
import useSWR from "swr";
|
|
599
|
+
import { createClient } from "pureq";
|
|
600
|
+
|
|
601
|
+
const api = createClient({ baseURL: "https://api.example.com" });
|
|
602
|
+
|
|
603
|
+
function useUser(id: string) {
|
|
604
|
+
return useSWR(
|
|
605
|
+
["user", id],
|
|
606
|
+
() => api.getJson<User>("/users/:id", { params: { id } }),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Separation of concerns:**
|
|
612
|
+
|
|
613
|
+
- **React Query / SWR** → cache lifecycle, stale-while-revalidate, background refetch, suspense, UI state
|
|
614
|
+
- **pureq** → retry, circuit breaking, timeout, dedup, concurrency, telemetry, error normalization
|
|
615
|
+
|
|
616
|
+
You get the best of both worlds.
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
## BFF / Backend Patterns
|
|
621
|
+
|
|
622
|
+
### BFF (Backend-For-Frontend)
|
|
623
|
+
|
|
624
|
+
A BFF aggregates multiple upstream APIs for the frontend. pureq gives you explicit per-dependency policy:
|
|
625
|
+
|
|
626
|
+
```ts
|
|
627
|
+
// One client per upstream service — explicit, isolated policies
|
|
628
|
+
const userService = createClient({ baseURL: "https://user-service.internal" })
|
|
629
|
+
.use(retry({ maxRetries: 2, delay: 150 }))
|
|
630
|
+
.use(circuitBreaker({ failureThreshold: 5, cooldownMs: 20_000 }))
|
|
631
|
+
.useRequestInterceptor((req) => ({
|
|
632
|
+
...req,
|
|
633
|
+
headers: { ...req.headers, "X-Internal-Auth": getServiceToken() },
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
const paymentService = createClient({ baseURL: "https://payment-service.internal" })
|
|
637
|
+
.use(retry({ maxRetries: 1, delay: 500 }))
|
|
638
|
+
.use(circuitBreaker({ failureThreshold: 3, cooldownMs: 60_000 }));
|
|
639
|
+
|
|
640
|
+
// BFF handler
|
|
641
|
+
async function handleGetUserProfile(userId: string) {
|
|
642
|
+
const user = await userService.getJson<User>("/users/:id", {
|
|
643
|
+
params: { id: userId },
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const paymentResult = await paymentService.getResult("/payments/user/:id", {
|
|
647
|
+
params: { id: userId },
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
...user,
|
|
652
|
+
payments: paymentResult.ok ? await paymentResult.data.json() : [],
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### Backend service-to-service
|
|
658
|
+
|
|
659
|
+
```ts
|
|
660
|
+
import { createClient, backendPreset } from "pureq";
|
|
661
|
+
|
|
662
|
+
let inventoryClient = createClient({
|
|
663
|
+
baseURL: "https://inventory.internal",
|
|
664
|
+
hooks: {
|
|
665
|
+
onRequestError: (event) => {
|
|
666
|
+
metrics.increment("inventory.request.error", { kind: event.errorKind });
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
for (const mw of backendPreset({
|
|
672
|
+
retry: { maxRetries: 3, delay: 250 },
|
|
673
|
+
circuitBreaker: { failureThreshold: 6, cooldownMs: 30_000 },
|
|
674
|
+
})) {
|
|
675
|
+
inventoryClient = inventoryClient.use(mw);
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## Adapters & Serializers
|
|
682
|
+
|
|
683
|
+
### Custom adapters
|
|
684
|
+
|
|
685
|
+
The default adapter uses the global `fetch`. You can swap it for tests or runtime-specific behavior:
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
// Test adapter
|
|
689
|
+
const testClient = createClient({
|
|
690
|
+
adapter: async (url, init) => {
|
|
691
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
692
|
+
status: 200,
|
|
693
|
+
headers: { "Content-Type": "application/json" },
|
|
694
|
+
});
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// Instrumented adapter
|
|
699
|
+
import { createInstrumentedAdapter, fetchAdapter } from "pureq";
|
|
700
|
+
|
|
701
|
+
const instrumented = createInstrumentedAdapter(fetchAdapter, {
|
|
702
|
+
onStart: (e) => console.log(`→ ${e.url}`),
|
|
703
|
+
onSuccess: (e) => console.log(`✓ ${e.durationMs}ms`),
|
|
704
|
+
onError: (e) => console.error(`✗ ${e.error}`),
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const client = createClient({ adapter: instrumented });
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
### Body serializers
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
import { createFormUrlEncodedSerializer } from "pureq";
|
|
714
|
+
|
|
715
|
+
const client = createClient({
|
|
716
|
+
bodySerializer: createFormUrlEncodedSerializer({ arrayMode: "comma" }),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
await client.post("/search", { tags: ["ts", "http"], q: "pureq" });
|
|
720
|
+
// body: tags=ts%2Chttp&q=pureq
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
### Binary Protocols (MessagePack, Protobuf)
|
|
724
|
+
|
|
725
|
+
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:
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
import { encode } from "@msgpack/msgpack";
|
|
729
|
+
|
|
730
|
+
// You can swap the body serializer to return binary formats
|
|
731
|
+
const msgpackSerializer = (data: unknown) => ({
|
|
732
|
+
body: encode(data), // Returns Uint8Array
|
|
733
|
+
headers: { "Content-Type": "application/x-msgpack" }
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const client = createClient({ bodySerializer: msgpackSerializer });
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
**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.
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
## Migration from fetch / axios
|
|
744
|
+
|
|
745
|
+
The smallest possible migration:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
// Before (fetch)
|
|
749
|
+
const response = await fetch("https://api.example.com/users/42");
|
|
750
|
+
const user = await response.json();
|
|
751
|
+
|
|
752
|
+
// After (pureq)
|
|
753
|
+
import { createClient } from "pureq";
|
|
754
|
+
const client = createClient({ baseURL: "https://api.example.com" });
|
|
755
|
+
const user = await client.getJson<User>("/users/:id", { params: { id: "42" } });
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
### Step-by-step adoption
|
|
759
|
+
|
|
760
|
+
1. **Wrap one API dependency** with `createClient()` — just `baseURL` and `headers`
|
|
761
|
+
2. **Replace repeated `fetch` calls** with `client.get()` / `client.post()`
|
|
762
|
+
3. **Move retry/timeout logic** from ad-hoc `try/catch` into middleware
|
|
763
|
+
4. **Use `*Result` variants** where you want explicit error handling
|
|
764
|
+
5. **Add diagnostics** to gain observability without changing call sites
|
|
765
|
+
6. **Only then** add caching, hedging, circuit breaking, guardrails, or presets
|
|
766
|
+
|
|
767
|
+
Each step is independently useful. You don't need everything on day one.
|
|
768
|
+
|
|
769
|
+
More details: [Migration guide](./docs/migration_guide.md) · [Codemod recipes](./tools/codemods/README.md)
|
|
770
|
+
|
|
771
|
+
---
|
|
772
|
+
|
|
773
|
+
## Runtime Compatibility
|
|
774
|
+
|
|
775
|
+
pureq works anywhere `fetch` is available:
|
|
776
|
+
|
|
777
|
+
| Runtime | Supported | Tested |
|
|
778
|
+
| ------- | --------- | ------ |
|
|
779
|
+
| Node.js 18+ | ✅ | CI matrix (18, 20, 22) |
|
|
780
|
+
| Deno | ✅ | — |
|
|
781
|
+
| Bun | ✅ | — |
|
|
782
|
+
| Modern browsers | ✅ | jsdom smoke test |
|
|
783
|
+
| Cloudflare Workers | ✅ | Edge runtime smoke test |
|
|
784
|
+
| Vercel Edge | ✅ | Edge runtime smoke test |
|
|
785
|
+
|
|
786
|
+
Zero dependencies. ESM-first. Tree-shakeable.
|
|
787
|
+
|
|
788
|
+
More details: [Runtime compatibility matrix](./docs/runtime_compatibility_matrix.md)
|
|
789
|
+
|
|
790
|
+
---
|
|
791
|
+
|
|
792
|
+
## API Reference
|
|
793
|
+
|
|
794
|
+
### Client
|
|
795
|
+
|
|
796
|
+
| Method | Returns | Description |
|
|
797
|
+
| ------ | ------- | ----------- |
|
|
798
|
+
| `createClient(options?)` | `PureqClient` | Create a new immutable client |
|
|
799
|
+
| `client.use(middleware)` | `PureqClient` | Add middleware (returns new client) |
|
|
800
|
+
| `client.useRequestInterceptor(fn)` | `PureqClient` | Add request interceptor |
|
|
801
|
+
| `client.useResponseInterceptor(fn)` | `PureqClient` | Add response interceptor |
|
|
802
|
+
|
|
803
|
+
### Request Methods
|
|
804
|
+
|
|
805
|
+
| Method | Throws? | Returns |
|
|
806
|
+
| ------ | ------- | ------- |
|
|
807
|
+
| `client.get(url, opts?)` | Yes | `Promise<HttpResponse>` |
|
|
808
|
+
| `client.getResult(url, opts?)` | Never | `Promise<Result<HttpResponse, PureqError>>` |
|
|
809
|
+
| `client.getJson<T>(url, opts?)` | Yes | `Promise<T>` |
|
|
810
|
+
| `client.getJsonResult<T>(url, opts?)` | Never | `Promise<Result<T, PureqError>>` |
|
|
811
|
+
| `client.post(url, body?, opts?)` | Yes | `Promise<HttpResponse>` |
|
|
812
|
+
| `client.postResult(url, body?, opts?)` | Never | `Promise<Result<HttpResponse, PureqError>>` |
|
|
813
|
+
| `client.postJson<T>(url, body?, opts?)` | Yes | `Promise<T>` |
|
|
814
|
+
| `client.postJsonResult<T>(url, body?, opts?)` | Never | `Promise<Result<T, PureqError>>` |
|
|
815
|
+
| `client.put(...)` / `putResult(...)` | — | Same pattern as post |
|
|
816
|
+
| `client.patch(...)` / `patchResult(...)` | — | Same pattern as post |
|
|
817
|
+
| `client.delete(...)` / `deleteResult(...)` | — | Same pattern as get |
|
|
818
|
+
| `client.fetch(url, init?)` | Yes | Familiar `fetch`-like API |
|
|
819
|
+
| `client.fetchResult(url, init?)` | Never | Result-wrapped fetch-like API |
|
|
820
|
+
| `client.fetchJson<T>(url, init?)` | Yes | fetch + JSON parse |
|
|
821
|
+
| `client.request(config)` | Yes | Low-level full config |
|
|
822
|
+
| `client.requestResult(config)` | Never | Low-level Result variant |
|
|
823
|
+
| `client.requestJson<T>(config)` | Yes | Low-level + JSON |
|
|
824
|
+
| `client.requestJsonResult<T>(config)` | Never | Low-level + JSON + Result |
|
|
825
|
+
|
|
826
|
+
### Middleware
|
|
827
|
+
|
|
828
|
+
| Middleware | Purpose |
|
|
829
|
+
| --------- | ------- |
|
|
830
|
+
| `retry(options)` | Exponential backoff, Retry-After, budget |
|
|
831
|
+
| `deadline(options)` | Total request budget across retries |
|
|
832
|
+
| `defaultTimeout(ms)` | Default per-request timeout |
|
|
833
|
+
| `circuitBreaker(options)` | Fail-fast on repeated failures |
|
|
834
|
+
| `concurrencyLimit(options)` | Cap in-flight requests |
|
|
835
|
+
| `dedupe(options?)` | Collapse duplicate concurrent requests |
|
|
836
|
+
| `hedge(options)` | Duplicate request for tail latency |
|
|
837
|
+
| `httpCache(options)` | In-memory cache with ETag/stale-if-error |
|
|
838
|
+
| `createOfflineQueue(options?)` | Offline mutation queue with replay |
|
|
839
|
+
| `idempotencyKey(options?)` | Auto-inject idempotency headers |
|
|
840
|
+
|
|
841
|
+
### Available Presets
|
|
842
|
+
|
|
843
|
+
| Preset | Best for |
|
|
844
|
+
| ------ | -------- |
|
|
845
|
+
| `resilientPreset()` | General-purpose production stack |
|
|
846
|
+
| `frontendPreset()` | User-facing requests with conservative policy |
|
|
847
|
+
| `bffPreset()` | BFF with auth propagation and upstream stability |
|
|
848
|
+
| `backendPreset()` | Service-to-service under sustained load |
|
|
849
|
+
|
|
850
|
+
### Observability Exports
|
|
851
|
+
|
|
852
|
+
| Export | Purpose |
|
|
853
|
+
| ------ | ------- |
|
|
854
|
+
| `createMiddlewareDiagnostics(options)` | Collect metrics and traces |
|
|
855
|
+
| `createConsoleDiagnosticsExporter()` | Console logging exporter |
|
|
856
|
+
| `createOpenTelemetryDiagnosticsExporter(meter)` | OTel metrics exporter |
|
|
857
|
+
| `mapToStandardHttpAttributes(event)` | OTel semantic conventions |
|
|
858
|
+
| `mapToAwsSemanticConventions(event)` | AWS X-Ray attributes |
|
|
859
|
+
| `mapToGcpSemanticConventions(event)` | GCP Cloud Trace attributes |
|
|
860
|
+
| `redactHeaders(headers, options?)` | Redact sensitive headers |
|
|
861
|
+
| `redactObjectFields(obj, options?)` | Redact fields by pattern |
|
|
862
|
+
| `redactUrlQueryParams(url, options?)` | Redact URL query params |
|
|
863
|
+
| `piiRedactionOptions` | Pre-built PII redaction profile |
|
|
864
|
+
| `authRedactionOptions` | Pre-built auth redaction profile |
|
|
865
|
+
|
|
866
|
+
### Adapters and Serializers
|
|
867
|
+
|
|
868
|
+
| Export | Purpose |
|
|
869
|
+
| ------ | ------- |
|
|
870
|
+
| `fetchAdapter` | Default global fetch adapter |
|
|
871
|
+
| `createInstrumentedAdapter(base, hooks)` | Adapter with lifecycle hooks |
|
|
872
|
+
| `jsonBodySerializer` | Default JSON body serializer |
|
|
873
|
+
| `createFormUrlEncodedSerializer(options?)` | Form URL-encoded serializer |
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
## Security
|
|
878
|
+
|
|
879
|
+
pureq takes a defense-in-depth approach to transport layer security:
|
|
880
|
+
|
|
881
|
+
- **Type-safe Path Templates**: `/users/:id` inherently protects against accidental payload leakage or malformed URL construction compared to manual string interpolation.
|
|
882
|
+
- **Resource Exhaustion Defense**: Middleware like `deadline()`, `defaultTimeout()`, and `concurrencyLimit()` help mitigate backend overloading and "Slow Loris" style denial-of-service on the client.
|
|
883
|
+
- **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.
|
|
884
|
+
- **Explicit Serialization**: Defining body serializers restricts accidental serialization of unintended properties compared to ad-hoc `JSON.stringify`.
|
|
885
|
+
|
|
886
|
+
*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.*
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
## Development
|
|
891
|
+
|
|
892
|
+
```bash
|
|
893
|
+
npm run typecheck # type checking
|
|
894
|
+
npm test # all tests
|
|
895
|
+
npm run test:ci # unit + integration + contract + stress + typecheck
|
|
896
|
+
npm run test:browser # browser runtime smoke test
|
|
897
|
+
npm run test:edge # edge runtime smoke test
|
|
898
|
+
npm run build # production build
|
|
899
|
+
npm run benchmark # performance benchmark
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
## Limitations
|
|
903
|
+
|
|
904
|
+
pureq is intentionally focused. A few limits are worth knowing:
|
|
905
|
+
|
|
906
|
+
- `httpCache()` is in-memory and process-local — not a distributed cache
|
|
907
|
+
- `hedge()` duplicates requests and should only be used for idempotent reads
|
|
908
|
+
- `circuitBreaker()` is per-process — for distributed circuit breaking use an external store
|
|
909
|
+
- JSON helpers are convenience methods, not a replacement for your domain model
|
|
910
|
+
- Diagnostics exporters are lightweight adapters, not a full telemetry SDK
|
|
911
|
+
|
|
912
|
+
These limits are by design. The library is meant to stay small, explicit, and composable.
|
|
913
|
+
|
|
914
|
+
## Documentation
|
|
915
|
+
|
|
916
|
+
| Document | Content |
|
|
917
|
+
| -------- | ------- |
|
|
918
|
+
| [Reliability Primitives](./docs/reliability_primitives.md) | retry, deadline, hedge, circuit breaker |
|
|
919
|
+
| [Cache & Offline](./docs/cache_and_offline.md) | httpCache, offlineQueue, stale-if-error |
|
|
920
|
+
| [Observability & Governance](./docs/observability_and_governance.md) | diagnostics, OTel, redaction, guardrails |
|
|
921
|
+
| [React Query Integration](./docs/integration_react_query.md) | detailed React Query patterns |
|
|
922
|
+
| [SWR Integration](./docs/integration_swr.md) | detailed SWR patterns |
|
|
923
|
+
| [BFF & Backend Templates](./docs/templates_bff_backend.md) | per-dependency client patterns |
|
|
924
|
+
| [Positioning](./docs/positioning_react_query_swr_bff_backend.md) | when to use what |
|
|
925
|
+
| [Migration Guide](./docs/migration_guide.md) | fetch/axios → pureq step by step |
|
|
926
|
+
| [Benchmarks](./docs/benchmarks.md) | methodology and baseline numbers |
|
|
927
|
+
| [Runtime Compatibility](./docs/runtime_compatibility_matrix.md) | supported runtimes |
|
|
928
|
+
| [Adoption Strategy](./docs/standard_adoption_strategy.md) | org-wide rollout playbook |
|
|
929
|
+
|
|
930
|
+
## License
|
|
931
|
+
|
|
932
|
+
[MIT](./LICENSE.md)
|