@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.
Files changed (295) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +932 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +2 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/scripts/benchmark.d.ts +2 -0
  8. package/dist/scripts/benchmark.d.ts.map +1 -0
  9. package/dist/scripts/benchmark.js +69 -0
  10. package/dist/scripts/benchmark.js.map +1 -0
  11. package/dist/scripts/edge-smoke-entry.d.ts +8 -0
  12. package/dist/scripts/edge-smoke-entry.d.ts.map +1 -0
  13. package/dist/scripts/edge-smoke-entry.js +23 -0
  14. package/dist/scripts/edge-smoke-entry.js.map +1 -0
  15. package/dist/src/adapters/fetchAdapter.d.ts +3 -0
  16. package/dist/src/adapters/fetchAdapter.d.ts.map +1 -0
  17. package/dist/src/adapters/fetchAdapter.js +4 -0
  18. package/dist/src/adapters/fetchAdapter.js.map +1 -0
  19. package/dist/src/adapters/instrumentedAdapter.d.ts +21 -0
  20. package/dist/src/adapters/instrumentedAdapter.d.ts.map +1 -0
  21. package/dist/src/adapters/instrumentedAdapter.js +25 -0
  22. package/dist/src/adapters/instrumentedAdapter.js.map +1 -0
  23. package/dist/src/client/createClient.d.ts +193 -0
  24. package/dist/src/client/createClient.d.ts.map +1 -0
  25. package/dist/src/client/createClient.js +310 -0
  26. package/dist/src/client/createClient.js.map +1 -0
  27. package/dist/src/executor/execute.d.ts +19 -0
  28. package/dist/src/executor/execute.d.ts.map +1 -0
  29. package/dist/src/executor/execute.js +121 -0
  30. package/dist/src/executor/execute.js.map +1 -0
  31. package/dist/src/index.d.ts +37 -0
  32. package/dist/src/index.d.ts.map +1 -0
  33. package/dist/src/index.js +36 -0
  34. package/dist/src/index.js.map +1 -0
  35. package/dist/src/middleware/circuitBreaker.d.ts +77 -0
  36. package/dist/src/middleware/circuitBreaker.d.ts.map +1 -0
  37. package/dist/src/middleware/circuitBreaker.js +246 -0
  38. package/dist/src/middleware/circuitBreaker.js.map +1 -0
  39. package/dist/src/middleware/circuitBreakerKeys.d.ts +5 -0
  40. package/dist/src/middleware/circuitBreakerKeys.d.ts.map +1 -0
  41. package/dist/src/middleware/circuitBreakerKeys.js +30 -0
  42. package/dist/src/middleware/circuitBreakerKeys.js.map +1 -0
  43. package/dist/src/middleware/compose.d.ts +9 -0
  44. package/dist/src/middleware/compose.d.ts.map +1 -0
  45. package/dist/src/middleware/compose.js +45 -0
  46. package/dist/src/middleware/compose.js.map +1 -0
  47. package/dist/src/middleware/concurrencyLimit.d.ts +11 -0
  48. package/dist/src/middleware/concurrencyLimit.d.ts.map +1 -0
  49. package/dist/src/middleware/concurrencyLimit.js +126 -0
  50. package/dist/src/middleware/concurrencyLimit.js.map +1 -0
  51. package/dist/src/middleware/deadline.d.ts +10 -0
  52. package/dist/src/middleware/deadline.d.ts.map +1 -0
  53. package/dist/src/middleware/deadline.js +99 -0
  54. package/dist/src/middleware/deadline.js.map +1 -0
  55. package/dist/src/middleware/dedupe.d.ts +13 -0
  56. package/dist/src/middleware/dedupe.d.ts.map +1 -0
  57. package/dist/src/middleware/dedupe.js +46 -0
  58. package/dist/src/middleware/dedupe.js.map +1 -0
  59. package/dist/src/middleware/defaultTimeout.d.ts +6 -0
  60. package/dist/src/middleware/defaultTimeout.d.ts.map +1 -0
  61. package/dist/src/middleware/defaultTimeout.js +23 -0
  62. package/dist/src/middleware/defaultTimeout.js.map +1 -0
  63. package/dist/src/middleware/diagnostics.d.ts +28 -0
  64. package/dist/src/middleware/diagnostics.d.ts.map +1 -0
  65. package/dist/src/middleware/diagnostics.js +131 -0
  66. package/dist/src/middleware/diagnostics.js.map +1 -0
  67. package/dist/src/middleware/diagnosticsExporters.d.ts +27 -0
  68. package/dist/src/middleware/diagnosticsExporters.d.ts.map +1 -0
  69. package/dist/src/middleware/diagnosticsExporters.js +45 -0
  70. package/dist/src/middleware/diagnosticsExporters.js.map +1 -0
  71. package/dist/src/middleware/hedge.d.ts +17 -0
  72. package/dist/src/middleware/hedge.d.ts.map +1 -0
  73. package/dist/src/middleware/hedge.js +125 -0
  74. package/dist/src/middleware/hedge.js.map +1 -0
  75. package/dist/src/middleware/httpCache.d.ts +14 -0
  76. package/dist/src/middleware/httpCache.d.ts.map +1 -0
  77. package/dist/src/middleware/httpCache.js +126 -0
  78. package/dist/src/middleware/httpCache.js.map +1 -0
  79. package/dist/src/middleware/idempotencyKey.d.ts +12 -0
  80. package/dist/src/middleware/idempotencyKey.d.ts.map +1 -0
  81. package/dist/src/middleware/idempotencyKey.js +33 -0
  82. package/dist/src/middleware/idempotencyKey.js.map +1 -0
  83. package/dist/src/middleware/offlineQueue.d.ts +78 -0
  84. package/dist/src/middleware/offlineQueue.d.ts.map +1 -0
  85. package/dist/src/middleware/offlineQueue.js +189 -0
  86. package/dist/src/middleware/offlineQueue.js.map +1 -0
  87. package/dist/src/middleware/presets.d.ts +31 -0
  88. package/dist/src/middleware/presets.d.ts.map +1 -0
  89. package/dist/src/middleware/presets.js +122 -0
  90. package/dist/src/middleware/presets.js.map +1 -0
  91. package/dist/src/middleware/retry.d.ts +27 -0
  92. package/dist/src/middleware/retry.d.ts.map +1 -0
  93. package/dist/src/middleware/retry.js +189 -0
  94. package/dist/src/middleware/retry.js.map +1 -0
  95. package/dist/src/middleware/stalePolicy.d.ts +14 -0
  96. package/dist/src/middleware/stalePolicy.d.ts.map +1 -0
  97. package/dist/src/middleware/stalePolicy.js +13 -0
  98. package/dist/src/middleware/stalePolicy.js.map +1 -0
  99. package/dist/src/observability/otelMapping.d.ts +13 -0
  100. package/dist/src/observability/otelMapping.d.ts.map +1 -0
  101. package/dist/src/observability/otelMapping.js +31 -0
  102. package/dist/src/observability/otelMapping.js.map +1 -0
  103. package/dist/src/observability/otelProfiles.d.ts +16 -0
  104. package/dist/src/observability/otelProfiles.d.ts.map +1 -0
  105. package/dist/src/observability/otelProfiles.js +66 -0
  106. package/dist/src/observability/otelProfiles.js.map +1 -0
  107. package/dist/src/observability/redaction.d.ts +39 -0
  108. package/dist/src/observability/redaction.d.ts.map +1 -0
  109. package/dist/src/observability/redaction.js +112 -0
  110. package/dist/src/observability/redaction.js.map +1 -0
  111. package/dist/src/policy/guardrails.d.ts +14 -0
  112. package/dist/src/policy/guardrails.d.ts.map +1 -0
  113. package/dist/src/policy/guardrails.js +36 -0
  114. package/dist/src/policy/guardrails.js.map +1 -0
  115. package/dist/src/response/response.d.ts +58 -0
  116. package/dist/src/response/response.d.ts.map +1 -0
  117. package/dist/src/response/response.js +91 -0
  118. package/dist/src/response/response.js.map +1 -0
  119. package/dist/src/serializers/formUrlEncodedSerializer.d.ts +6 -0
  120. package/dist/src/serializers/formUrlEncodedSerializer.d.ts.map +1 -0
  121. package/dist/src/serializers/formUrlEncodedSerializer.js +38 -0
  122. package/dist/src/serializers/formUrlEncodedSerializer.js.map +1 -0
  123. package/dist/src/serializers/jsonBodySerializer.d.ts +3 -0
  124. package/dist/src/serializers/jsonBodySerializer.d.ts.map +1 -0
  125. package/dist/src/serializers/jsonBodySerializer.js +24 -0
  126. package/dist/src/serializers/jsonBodySerializer.js.map +1 -0
  127. package/dist/src/types/events.d.ts +49 -0
  128. package/dist/src/types/events.d.ts.map +1 -0
  129. package/dist/src/types/events.js +2 -0
  130. package/dist/src/types/events.js.map +1 -0
  131. package/dist/src/types/http.d.ts +78 -0
  132. package/dist/src/types/http.d.ts.map +1 -0
  133. package/dist/src/types/http.js +2 -0
  134. package/dist/src/types/http.js.map +1 -0
  135. package/dist/src/types/internal.d.ts +20 -0
  136. package/dist/src/types/internal.d.ts.map +1 -0
  137. package/dist/src/types/internal.js +2 -0
  138. package/dist/src/types/internal.js.map +1 -0
  139. package/dist/src/types/result.d.ts +32 -0
  140. package/dist/src/types/result.d.ts.map +1 -0
  141. package/dist/src/types/result.js +66 -0
  142. package/dist/src/types/result.js.map +1 -0
  143. package/dist/src/utils/crypto.d.ts +8 -0
  144. package/dist/src/utils/crypto.d.ts.map +1 -0
  145. package/dist/src/utils/crypto.js +21 -0
  146. package/dist/src/utils/crypto.js.map +1 -0
  147. package/dist/src/utils/policyTrace.d.ts +17 -0
  148. package/dist/src/utils/policyTrace.d.ts.map +1 -0
  149. package/dist/src/utils/policyTrace.js +34 -0
  150. package/dist/src/utils/policyTrace.js.map +1 -0
  151. package/dist/src/utils/stableKey.d.ts +17 -0
  152. package/dist/src/utils/stableKey.d.ts.map +1 -0
  153. package/dist/src/utils/stableKey.js +41 -0
  154. package/dist/src/utils/stableKey.js.map +1 -0
  155. package/dist/src/utils/url.d.ts +22 -0
  156. package/dist/src/utils/url.d.ts.map +1 -0
  157. package/dist/src/utils/url.js +2 -0
  158. package/dist/src/utils/url.js.map +1 -0
  159. package/dist/tests/adapter-serializer.test.d.ts +2 -0
  160. package/dist/tests/adapter-serializer.test.d.ts.map +1 -0
  161. package/dist/tests/adapter-serializer.test.js +59 -0
  162. package/dist/tests/adapter-serializer.test.js.map +1 -0
  163. package/dist/tests/browser-runtime.smoke.test.d.ts +2 -0
  164. package/dist/tests/browser-runtime.smoke.test.d.ts.map +1 -0
  165. package/dist/tests/browser-runtime.smoke.test.js +17 -0
  166. package/dist/tests/browser-runtime.smoke.test.js.map +1 -0
  167. package/dist/tests/circuit-breaker.test.d.ts +2 -0
  168. package/dist/tests/circuit-breaker.test.d.ts.map +1 -0
  169. package/dist/tests/circuit-breaker.test.js +184 -0
  170. package/dist/tests/circuit-breaker.test.js.map +1 -0
  171. package/dist/tests/client.integration.d.ts +2 -0
  172. package/dist/tests/client.integration.d.ts.map +1 -0
  173. package/dist/tests/client.integration.js +44 -0
  174. package/dist/tests/client.integration.js.map +1 -0
  175. package/dist/tests/client.integration.test.d.ts +2 -0
  176. package/dist/tests/client.integration.test.d.ts.map +1 -0
  177. package/dist/tests/client.integration.test.js +63 -0
  178. package/dist/tests/client.integration.test.js.map +1 -0
  179. package/dist/tests/compose.test.d.ts +2 -0
  180. package/dist/tests/compose.test.d.ts.map +1 -0
  181. package/dist/tests/compose.test.js +24 -0
  182. package/dist/tests/compose.test.js.map +1 -0
  183. package/dist/tests/concurrency-limit.test.d.ts +2 -0
  184. package/dist/tests/concurrency-limit.test.d.ts.map +1 -0
  185. package/dist/tests/concurrency-limit.test.js +87 -0
  186. package/dist/tests/concurrency-limit.test.js.map +1 -0
  187. package/dist/tests/deadline-middleware.test.d.ts +2 -0
  188. package/dist/tests/deadline-middleware.test.d.ts.map +1 -0
  189. package/dist/tests/deadline-middleware.test.js +38 -0
  190. package/dist/tests/deadline-middleware.test.js.map +1 -0
  191. package/dist/tests/dedupe-middleware.test.d.ts +2 -0
  192. package/dist/tests/dedupe-middleware.test.d.ts.map +1 -0
  193. package/dist/tests/dedupe-middleware.test.js +56 -0
  194. package/dist/tests/dedupe-middleware.test.js.map +1 -0
  195. package/dist/tests/default-timeout.test.d.ts +2 -0
  196. package/dist/tests/default-timeout.test.d.ts.map +1 -0
  197. package/dist/tests/default-timeout.test.js +28 -0
  198. package/dist/tests/default-timeout.test.js.map +1 -0
  199. package/dist/tests/diagnostics-exporters.test.d.ts +2 -0
  200. package/dist/tests/diagnostics-exporters.test.d.ts.map +1 -0
  201. package/dist/tests/diagnostics-exporters.test.js +41 -0
  202. package/dist/tests/diagnostics-exporters.test.js.map +1 -0
  203. package/dist/tests/diagnostics.test.d.ts +2 -0
  204. package/dist/tests/diagnostics.test.d.ts.map +1 -0
  205. package/dist/tests/diagnostics.test.js +38 -0
  206. package/dist/tests/diagnostics.test.js.map +1 -0
  207. package/dist/tests/error-metadata.test.d.ts +2 -0
  208. package/dist/tests/error-metadata.test.d.ts.map +1 -0
  209. package/dist/tests/error-metadata.test.js +23 -0
  210. package/dist/tests/error-metadata.test.js.map +1 -0
  211. package/dist/tests/execute-timeout.test.d.ts +2 -0
  212. package/dist/tests/execute-timeout.test.d.ts.map +1 -0
  213. package/dist/tests/execute-timeout.test.js +30 -0
  214. package/dist/tests/execute-timeout.test.js.map +1 -0
  215. package/dist/tests/form-serializer.test.d.ts +2 -0
  216. package/dist/tests/form-serializer.test.d.ts.map +1 -0
  217. package/dist/tests/form-serializer.test.js +16 -0
  218. package/dist/tests/form-serializer.test.js.map +1 -0
  219. package/dist/tests/hedge.test.d.ts +2 -0
  220. package/dist/tests/hedge.test.d.ts.map +1 -0
  221. package/dist/tests/hedge.test.js +45 -0
  222. package/dist/tests/hedge.test.js.map +1 -0
  223. package/dist/tests/http-cache.test.d.ts +2 -0
  224. package/dist/tests/http-cache.test.d.ts.map +1 -0
  225. package/dist/tests/http-cache.test.js +60 -0
  226. package/dist/tests/http-cache.test.js.map +1 -0
  227. package/dist/tests/idempotency-key.test.d.ts +2 -0
  228. package/dist/tests/idempotency-key.test.d.ts.map +1 -0
  229. package/dist/tests/idempotency-key.test.js +31 -0
  230. package/dist/tests/idempotency-key.test.js.map +1 -0
  231. package/dist/tests/instrumented-adapter.test.d.ts +2 -0
  232. package/dist/tests/instrumented-adapter.test.d.ts.map +1 -0
  233. package/dist/tests/instrumented-adapter.test.js +33 -0
  234. package/dist/tests/instrumented-adapter.test.js.map +1 -0
  235. package/dist/tests/interceptor-order.test.d.ts +2 -0
  236. package/dist/tests/interceptor-order.test.d.ts.map +1 -0
  237. package/dist/tests/interceptor-order.test.js +38 -0
  238. package/dist/tests/interceptor-order.test.js.map +1 -0
  239. package/dist/tests/json-helper.test.d.ts +2 -0
  240. package/dist/tests/json-helper.test.d.ts.map +1 -0
  241. package/dist/tests/json-helper.test.js +42 -0
  242. package/dist/tests/json-helper.test.js.map +1 -0
  243. package/dist/tests/observability.test.d.ts +2 -0
  244. package/dist/tests/observability.test.d.ts.map +1 -0
  245. package/dist/tests/observability.test.js +59 -0
  246. package/dist/tests/observability.test.js.map +1 -0
  247. package/dist/tests/offline-queue.test.d.ts +2 -0
  248. package/dist/tests/offline-queue.test.d.ts.map +1 -0
  249. package/dist/tests/offline-queue.test.js +35 -0
  250. package/dist/tests/offline-queue.test.js.map +1 -0
  251. package/dist/tests/otel-mapping.test.d.ts +2 -0
  252. package/dist/tests/otel-mapping.test.d.ts.map +1 -0
  253. package/dist/tests/otel-mapping.test.js +20 -0
  254. package/dist/tests/otel-mapping.test.js.map +1 -0
  255. package/dist/tests/policy-guardrails.test.d.ts +2 -0
  256. package/dist/tests/policy-guardrails.test.d.ts.map +1 -0
  257. package/dist/tests/policy-guardrails.test.js +25 -0
  258. package/dist/tests/policy-guardrails.test.js.map +1 -0
  259. package/dist/tests/presets.test.d.ts +2 -0
  260. package/dist/tests/presets.test.d.ts.map +1 -0
  261. package/dist/tests/presets.test.js +41 -0
  262. package/dist/tests/presets.test.js.map +1 -0
  263. package/dist/tests/public-api.contract.d.ts +2 -0
  264. package/dist/tests/public-api.contract.d.ts.map +1 -0
  265. package/dist/tests/public-api.contract.js +14 -0
  266. package/dist/tests/public-api.contract.js.map +1 -0
  267. package/dist/tests/public-api.contract.test.d.ts +2 -0
  268. package/dist/tests/public-api.contract.test.d.ts.map +1 -0
  269. package/dist/tests/public-api.contract.test.js +25 -0
  270. package/dist/tests/public-api.contract.test.js.map +1 -0
  271. package/dist/tests/redaction.test.d.ts +2 -0
  272. package/dist/tests/redaction.test.d.ts.map +1 -0
  273. package/dist/tests/redaction.test.js +23 -0
  274. package/dist/tests/redaction.test.js.map +1 -0
  275. package/dist/tests/retry-after-budget.test.d.ts +2 -0
  276. package/dist/tests/retry-after-budget.test.d.ts.map +1 -0
  277. package/dist/tests/retry-after-budget.test.js +72 -0
  278. package/dist/tests/retry-after-budget.test.js.map +1 -0
  279. package/dist/tests/retry-policy-trace.test.d.ts +2 -0
  280. package/dist/tests/retry-policy-trace.test.d.ts.map +1 -0
  281. package/dist/tests/retry-policy-trace.test.js +35 -0
  282. package/dist/tests/retry-policy-trace.test.js.map +1 -0
  283. package/dist/tests/retry-policy.test.d.ts +2 -0
  284. package/dist/tests/retry-policy.test.d.ts.map +1 -0
  285. package/dist/tests/retry-policy.test.js +51 -0
  286. package/dist/tests/retry-policy.test.js.map +1 -0
  287. package/dist/tests/retry.stress.d.ts +2 -0
  288. package/dist/tests/retry.stress.d.ts.map +1 -0
  289. package/dist/tests/retry.stress.js +21 -0
  290. package/dist/tests/retry.stress.js.map +1 -0
  291. package/dist/tests/retry.stress.test.d.ts +2 -0
  292. package/dist/tests/retry.stress.test.d.ts.map +1 -0
  293. package/dist/tests/retry.stress.test.js +21 -0
  294. package/dist/tests/retry.stress.test.js.map +1 -0
  295. 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)