react-native-fetch-client 0.0.0-stage → 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +22 -0
- package/README.md +806 -2
- package/dist/index.cjs +2888 -0
- package/dist/index.d.cts +1744 -0
- package/dist/index.d.ts +1744 -0
- package/dist/index.mjs +2849 -0
- package/package.json +76 -4
package/README.md
CHANGED
|
@@ -1,3 +1,807 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-native-fetch-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **A high-performance, Fetch-native HTTP client for TypeScript & JavaScript (React Native, Expo, modern browsers, and Node.js).**
|
|
4
|
+
> Zero runtime dependencies • Dual ESM & CJS • In-flight Request Deduplication • Response Caching • Auth Mutex • Offline Queue • Streaming Progress
|
|
5
|
+
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](package.json)
|
|
9
|
+
[](test)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 📖 Developer Documentation Guides
|
|
14
|
+
|
|
15
|
+
For in-depth explanations, execution pipeline flows, and implementation guides, explore the dedicated documentation suite:
|
|
16
|
+
|
|
17
|
+
* 🏛️ **[Architecture & Execution Pipeline Flow](./docs/architecture-flow.md)** — Step-by-step trace through the 16-stage request pipeline.
|
|
18
|
+
* 🛠️ **[Core Features & Usage Guide](./docs/core-features-guide.md)** — Client setup, HTTP methods, path params, form encoding, safe requests, and unwrap.
|
|
19
|
+
* ⚡ **[Caching & Stale-While-Revalidate](./docs/caching-and-swr.md)** — In-memory LRU, persistent storage adapters (MMKV/AsyncStorage), ETag/304, and SWR.
|
|
20
|
+
* 🛡️ **[Resilience, Retries & Offline Queue](./docs/resilience-and-offline.md)** — Exponential backoff, jitter, circuit breakers, concurrency throttling, and offline replay.
|
|
21
|
+
* 🤖 **[Streaming, AI & File Uploads](./docs/streaming-and-uploads.md)** — SSE & AI LLM token streams, WHATWG streams, file uploads, and upload/download progress.
|
|
22
|
+
* 🔑 **[Authentication & Security](./docs/auth-and-security.md)** — Bearer presets, single-flight 401 refresh mutex, token rotation, and header redaction.
|
|
23
|
+
* 🔄 **[Auto-Pagination Helper](./docs/auto-pagination.md)** — Traversal strategies (`page`, `offset`, `cursor`, `linkHeader`) and async generator streaming.
|
|
24
|
+
* 🧪 **[Testing & Mock Adapter Guide](./docs/testing-and-mocking.md)** — Declarative unit testing (`createMockAdapter`), route stubbing, and call assertions.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Table of Contents
|
|
29
|
+
|
|
30
|
+
- [Why react-native-fetch-client?](#why-react-native-fetch-client)
|
|
31
|
+
- [Installation](#installation)
|
|
32
|
+
- [Developer Documentation Guides](#-developer-documentation-guides)
|
|
33
|
+
- [Quick Start (TypeScript & JavaScript)](#quick-start)
|
|
34
|
+
- [TypeScript (ESM)](#1-typescript-esm)
|
|
35
|
+
- [JavaScript (ES Modules - `import`)](#2-javascript-es-modules---import)
|
|
36
|
+
- [JavaScript (CommonJS - `require`)](#3-javascript-commonjs---require)
|
|
37
|
+
- [HTTP Methods & Shorthands](#http-methods--shorthands)
|
|
38
|
+
- [Standard Schema Validation (`schema`)](#standard-schema-validation-schema)
|
|
39
|
+
- [Auto-Pagination Helper (`api.paginate`)](#auto-pagination-helper-apipaginate)
|
|
40
|
+
- [Native AI & SSE Streaming (`api.stream`)](#native-ai--sse-streaming-apistream)
|
|
41
|
+
- [Go/Rust-Style Safe Requests (`api.safe.*`)](#gorust-style-safe-requests-apisafe)
|
|
42
|
+
- [Client Concurrency Throttler (`maxConcurrentRequests`)](#client-concurrency-throttler-maxconcurrentrequests)
|
|
43
|
+
- [Circuit Breaker Pattern (`circuitBreaker`)](#circuit-breaker-pattern-circuitbreaker)
|
|
44
|
+
- [RFC 7234 Conditional Caching (`ETag` & `304 Not Modified`)](#rfc-7234-conditional-caching-etag--304-not-modified)
|
|
45
|
+
- [Persistent Storage Response Cache (`storage`)](#persistent-storage-response-cache-storage)
|
|
46
|
+
- [Stale-While-Revalidate (`strategy: 'stale-while-revalidate'`)](#stale-while-revalidate-strategy-stale-while-revalidate)
|
|
47
|
+
- [Declarative Testing Mock Adapter (`createMockAdapter`)](#declarative-testing-mock-adapter-createmockadapter)
|
|
48
|
+
- [Path Parameter Interpolation (`pathParams`)](#path-parameter-interpolation-pathparams)
|
|
49
|
+
- [Bearer Token Preset (`bearerToken`)](#bearer-token-preset-bearertoken)
|
|
50
|
+
- [File Upload Helper (`api.upload`)](#file-upload-helper-apiupload)
|
|
51
|
+
- [Response Unwrapping (`unwrap: true`)](#response-unwrapping-unwrap-true)
|
|
52
|
+
- [Diagnostic Debug Logger (`debug: true`)](#diagnostic-debug-logger-debug-true)
|
|
53
|
+
- [In-Flight Request Deduplication](#in-flight-request-deduplication)
|
|
54
|
+
- [Response Caching (LRU)](#response-caching-lru)
|
|
55
|
+
- [Resilient Retry Engine](#resilient-retry-engine)
|
|
56
|
+
- [Auth & Token Refresh Mutex](#auth--token-refresh-mutex)
|
|
57
|
+
- [Upload & Download Progress](#upload--download-progress)
|
|
58
|
+
- [Offline Request Queue](#offline-request-queue)
|
|
59
|
+
- [Node.js Enterprise Proxies & Dispatcher](#nodejs-enterprise-proxies--dispatcher)
|
|
60
|
+
- [Migration Guide (From Axios)](#migration-guide-from-axios)
|
|
61
|
+
- [Testing Locally with Your Team](#testing-locally-with-your-team)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Why react-native-fetch-client?
|
|
66
|
+
|
|
67
|
+
Traditional HTTP clients like Axios were built over a decade ago around browser `XMLHttpRequest` (XHR), requiring polyfills and bulky dependencies on React Native, Expo, and modern Edge runtimes. Moreover, adding retries, token refresh mutexes, and caching requires stitching together 4 to 6 separate third-party packages.
|
|
68
|
+
|
|
69
|
+
`react-native-fetch-client` provides the complete modern networking stack in **one single, zero-dependency package**:
|
|
70
|
+
|
|
71
|
+
1. **Zero Production Dependencies (`dependencies: {}`)**: Eliminates supply-chain risks, CVE vulnerabilities, and `node_modules` bloat.
|
|
72
|
+
2. **First-Class TypeScript & Pure JavaScript**: Strict TypeScript generics plus full CommonJS (`require`) and ES Module (`import`) support.
|
|
73
|
+
3. **Native Multi-Runtime**: 100% compliant with WHATWG standards (`fetch`, `ReadableStream`, `AbortController`). Identical behavior across React Native, Expo, iOS, Android, modern browsers, Bun, and Node.js 18+.
|
|
74
|
+
4. **Single-Flight Auth Mutex**: Solves the classic race condition where parallel requests fail on 401s and trigger multiple refresh token calls.
|
|
75
|
+
5. **In-Flight Deduplication**: Coalesces concurrent identical `GET` requests into a single network flight with partial cancellation safety.
|
|
76
|
+
6. **Mutation-Aware LRU Cache**: Automatically invalidates cached GET routes whenever a POST/PUT/PATCH/DELETE mutation succeeds.
|
|
77
|
+
7. **Persistent Offline Queue**: Queues mutations offline and replays them sequentially with RFC-standard `Idempotency-Key` headers when reconnected.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install react-native-fetch-client
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or using other package managers:
|
|
88
|
+
```bash
|
|
89
|
+
yarn add react-native-fetch-client
|
|
90
|
+
pnpm add react-native-fetch-client
|
|
91
|
+
bun add react-native-fetch-client
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Quick Start
|
|
97
|
+
|
|
98
|
+
### 1. TypeScript (ESM)
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { createClient } from 'react-native-fetch-client';
|
|
102
|
+
|
|
103
|
+
interface User {
|
|
104
|
+
id: number;
|
|
105
|
+
name: string;
|
|
106
|
+
email: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const api = createClient({
|
|
110
|
+
baseURL: 'https://api.example.com',
|
|
111
|
+
timeout: 10_000,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Strongly typed GET request
|
|
115
|
+
const response = await api.get<User[]>('/users');
|
|
116
|
+
console.log(response.status); // 200
|
|
117
|
+
console.log(response.data); // User[]
|
|
118
|
+
|
|
119
|
+
// Strongly typed POST request
|
|
120
|
+
const created = await api.post<User>('/users', {
|
|
121
|
+
name: 'Jane Doe',
|
|
122
|
+
email: 'jane@example.com',
|
|
123
|
+
});
|
|
124
|
+
console.log('Created ID:', created.data.id);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2. JavaScript (ES Modules - `import`)
|
|
128
|
+
|
|
129
|
+
Works in modern frontend projects (React, Next.js, Vue, Vite) and Node.js (`"type": "module"`):
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
import { createClient } from 'react-native-fetch-client';
|
|
133
|
+
|
|
134
|
+
const api = createClient({
|
|
135
|
+
baseURL: 'https://api.example.com',
|
|
136
|
+
timeout: 10000,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
async function main() {
|
|
140
|
+
const res = await api.get('/users');
|
|
141
|
+
console.log(res.data);
|
|
142
|
+
|
|
143
|
+
const newUser = await api.post('/users', {
|
|
144
|
+
name: 'Jane Doe',
|
|
145
|
+
email: 'jane@example.com',
|
|
146
|
+
});
|
|
147
|
+
console.log('Created user:', newUser.data);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main();
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 3. JavaScript (CommonJS - `require`)
|
|
154
|
+
|
|
155
|
+
Works in standard Node.js scripts, Express backends, and CommonJS projects:
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
const { createClient, isHttpError } = require('react-native-fetch-client');
|
|
159
|
+
|
|
160
|
+
const api = createClient({
|
|
161
|
+
baseURL: 'https://api.example.com',
|
|
162
|
+
timeout: 10000,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
async function main() {
|
|
166
|
+
try {
|
|
167
|
+
const res = await api.get('/users');
|
|
168
|
+
console.log(res.data);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (isHttpError(err)) {
|
|
171
|
+
console.error('HTTP Error:', err.status, err.data);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
main();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## HTTP Methods & Shorthands
|
|
182
|
+
|
|
183
|
+
Available in both TypeScript and JavaScript:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// GET
|
|
187
|
+
const res = await api.get(url, config);
|
|
188
|
+
|
|
189
|
+
// POST
|
|
190
|
+
const res = await api.post(url, body, config);
|
|
191
|
+
|
|
192
|
+
// PUT
|
|
193
|
+
const res = await api.put(url, body, config);
|
|
194
|
+
|
|
195
|
+
// PATCH
|
|
196
|
+
const res = await api.patch(url, body, config);
|
|
197
|
+
|
|
198
|
+
// DELETE
|
|
199
|
+
const res = await api.delete(url, config);
|
|
200
|
+
|
|
201
|
+
// HEAD
|
|
202
|
+
const res = await api.head(url, config);
|
|
203
|
+
|
|
204
|
+
// OPTIONS
|
|
205
|
+
const res = await api.options(url, config);
|
|
206
|
+
|
|
207
|
+
// Generic Request
|
|
208
|
+
const res = await api.request({
|
|
209
|
+
url: '/custom',
|
|
210
|
+
method: 'POST',
|
|
211
|
+
body: { foo: 'bar' },
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Request Configuration
|
|
218
|
+
|
|
219
|
+
Options can be specified globally in `createClient(config)` or overridden per-request:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
const config = {
|
|
223
|
+
baseURL: 'https://api.example.com',
|
|
224
|
+
timeout: 10000, // Timeout in ms (0 = disabled)
|
|
225
|
+
headers: { 'Accept': 'application/json' },
|
|
226
|
+
params: { page: 1, limit: 20 }, // Query parameters: ?page=1&limit=20
|
|
227
|
+
responseType: 'json', // 'json' | 'text' | 'blob' | 'arrayBuffer' | 'none'
|
|
228
|
+
credentials: 'same-origin', // 'same-origin' | 'include' | 'omit'
|
|
229
|
+
redirect: 'follow', // 'follow' | 'manual' | 'error'
|
|
230
|
+
formUrlEncoded: false, // Serializes plain objects to URL-encoded form
|
|
231
|
+
signal: controller.signal, // Caller cancellation signal
|
|
232
|
+
onDownloadProgress: (event) => {}, // Streaming download callback
|
|
233
|
+
onUploadProgress: (event) => {}, // Outgoing upload progress callback
|
|
234
|
+
retry: { attempts: 3 }, // Retry policy
|
|
235
|
+
cache: { enabled: true, ttl: 30000 }, // Response cache
|
|
236
|
+
dedup: { enabled: true }, // In-flight request deduplication
|
|
237
|
+
auth: { skip: true }, // Bypass token injection for public routes
|
|
238
|
+
dispatcher: customAgent, // Undici ProxyAgent (Node.js proxies)
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Response Structure
|
|
245
|
+
|
|
246
|
+
Every successful call resolves to an `HttpResponse` object:
|
|
247
|
+
|
|
248
|
+
```javascript
|
|
249
|
+
const response = await api.get('/users');
|
|
250
|
+
|
|
251
|
+
console.log(response.data); // Parsed response body (JSON, text, or Blob)
|
|
252
|
+
console.log(response.status); // HTTP status code (e.g. 200)
|
|
253
|
+
console.log(response.statusText); // HTTP status text (e.g. "OK")
|
|
254
|
+
console.log(response.headers); // Lowercased response headers object
|
|
255
|
+
console.log(response.cached); // true if returned from local LRU cache
|
|
256
|
+
console.log(response.fromDedup); // true if shared via concurrent in-flight flight
|
|
257
|
+
console.log(response.config); // Resolved configuration used for request
|
|
258
|
+
console.log(response.request); // Native WHATWG Request object
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Error Handling
|
|
264
|
+
|
|
265
|
+
Errors thrown by `react-native-fetch-client` are typed subclasses of `FetchNativeError`. Type guard functions are exported for safe discrimination:
|
|
266
|
+
|
|
267
|
+
#### TypeScript:
|
|
268
|
+
```typescript
|
|
269
|
+
import {
|
|
270
|
+
isHttpError,
|
|
271
|
+
isNetworkError,
|
|
272
|
+
isTimeoutError,
|
|
273
|
+
isCancelError,
|
|
274
|
+
isRetryExhaustedError,
|
|
275
|
+
} from 'react-native-fetch-client';
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
await api.get('/orders');
|
|
279
|
+
} catch (err: unknown) {
|
|
280
|
+
if (isHttpError(err)) {
|
|
281
|
+
// Non-2xx response from the server (e.g. 400, 404, 500)
|
|
282
|
+
console.error(`HTTP ${err.status}:`, err.data);
|
|
283
|
+
console.error('Headers:', err.headers);
|
|
284
|
+
} else if (isTimeoutError(err)) {
|
|
285
|
+
// Request timed out past config.timeout
|
|
286
|
+
console.error(`Timed out after ${err.timeoutMs}ms`);
|
|
287
|
+
} else if (isCancelError(err)) {
|
|
288
|
+
// Request was aborted by caller via AbortController
|
|
289
|
+
console.warn('Request canceled by caller');
|
|
290
|
+
} else if (isRetryExhaustedError(err)) {
|
|
291
|
+
// Retried N times and all attempts failed
|
|
292
|
+
console.error(`Failed after ${err.attempts} attempts. Last error:`, err.lastError);
|
|
293
|
+
} else if (isNetworkError(err)) {
|
|
294
|
+
// Offline, DNS resolution failure, or TCP connection reset
|
|
295
|
+
console.error('Network connection error:', err.message);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### JavaScript:
|
|
301
|
+
```javascript
|
|
302
|
+
const { isHttpError, isTimeoutError, isCancelError } = require('react-native-fetch-client');
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await api.get('/orders');
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (isHttpError(err)) {
|
|
308
|
+
console.error('Server returned HTTP', err.status, err.data);
|
|
309
|
+
} else if (isTimeoutError(err)) {
|
|
310
|
+
console.error('Request timed out!');
|
|
311
|
+
} else if (isCancelError(err)) {
|
|
312
|
+
console.warn('Request was aborted.');
|
|
313
|
+
} else {
|
|
314
|
+
console.error('Network or system error:', err.message);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Interceptors
|
|
322
|
+
|
|
323
|
+
Interceptors allow you to transform outgoing requests or inspect/recover from responses:
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
// 1. Add Request Interceptor
|
|
327
|
+
const reqId = api.interceptors.request.use(
|
|
328
|
+
(config) => {
|
|
329
|
+
config.headers = {
|
|
330
|
+
...config.headers,
|
|
331
|
+
'X-Client-Platform': 'mobile',
|
|
332
|
+
};
|
|
333
|
+
return config;
|
|
334
|
+
},
|
|
335
|
+
(error) => Promise.reject(error)
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// 2. Add Response Interceptor
|
|
339
|
+
const resId = api.interceptors.response.use(
|
|
340
|
+
(response) => {
|
|
341
|
+
return response;
|
|
342
|
+
},
|
|
343
|
+
async (error) => {
|
|
344
|
+
if (error.status === 403) {
|
|
345
|
+
console.warn('Forbidden! Redirecting to permissions page...');
|
|
346
|
+
}
|
|
347
|
+
return Promise.reject(error);
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// 3. Eject Interceptors
|
|
352
|
+
api.interceptors.request.eject(reqId);
|
|
353
|
+
api.interceptors.response.eject(resId);
|
|
354
|
+
|
|
355
|
+
// 4. Clear all interceptors
|
|
356
|
+
api.interceptors.request.clear();
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Standard Schema Validation (`schema`)
|
|
362
|
+
|
|
363
|
+
Validate and parse incoming response payloads directly at runtime with **zero dependencies**. `react-native-fetch-client` uses duck-typing to seamlessly support:
|
|
364
|
+
- [Standard Schema v1 (`~standard`)](https://standardschema.dev/) (Zod 3.24+, Valibot, ArkType)
|
|
365
|
+
- Zod-like schemas (`.parse()`, `.safeParse()`)
|
|
366
|
+
- Plain validation functions `(data) => T`
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { z } from 'zod';
|
|
370
|
+
|
|
371
|
+
const UserSchema = z.object({
|
|
372
|
+
id: z.number(),
|
|
373
|
+
name: z.string(),
|
|
374
|
+
email: z.string().email(),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Throws ValidationError if response payload does not conform to the schema:
|
|
378
|
+
const response = await api.get('/user/me', {
|
|
379
|
+
schema: UserSchema,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
console.log(response.data.email); // Fully typed and runtime-validated!
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Native AI & SSE Streaming (`api.stream`)
|
|
388
|
+
|
|
389
|
+
Stream Server-Sent Events (SSE) or chunked LLM completions line-by-line using standard WHATWG `ReadableStream` and `AsyncGenerator`:
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
// Connects to SSE endpoint with Accept: text/event-stream
|
|
393
|
+
for await (const chunk of api.stream('/v1/chat/completions', {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
body: { model: 'gpt-4o', stream: true, messages: [{ role: 'user', content: 'Hello!' }] },
|
|
396
|
+
})) {
|
|
397
|
+
console.log(`Event: ${chunk.event}, Data: ${chunk.data}`);
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Go/Rust-Style Safe Requests (`api.safe.*`)
|
|
404
|
+
|
|
405
|
+
Eliminate `try/catch` boilerplate. The `api.safe.*` namespace returns clean `[data, error]` result tuples:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
const [user, error] = await api.safe.get<User>('/users/123');
|
|
409
|
+
|
|
410
|
+
if (error) {
|
|
411
|
+
console.error(`Request failed (${error.code}):`, error.message);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log('User loaded:', user.name);
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Client Concurrency Throttler (`maxConcurrentRequests`)
|
|
421
|
+
|
|
422
|
+
Cap the maximum number of simultaneous in-flight sockets across your entire application to avoid browser connection limits (HTTP/1.1 6-socket bottleneck) or mobile network congestion:
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
const api = createClient({
|
|
426
|
+
baseURL: 'https://api.example.com',
|
|
427
|
+
maxConcurrentRequests: 4, // Max 4 concurrent sockets; excess wait in FIFO queue
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## Circuit Breaker Pattern (`circuitBreaker`)
|
|
434
|
+
|
|
435
|
+
Prevent cascading outages when upstream microservices experience downtime. Automatically fails fast locally when consecutive errors exceed a configured threshold:
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const api = createClient({
|
|
439
|
+
baseURL: 'https://api.example.com',
|
|
440
|
+
circuitBreaker: {
|
|
441
|
+
enabled: true,
|
|
442
|
+
failureThreshold: 5, // Trip to OPEN after 5 consecutive 5xx/network failures
|
|
443
|
+
resetTimeoutMs: 30000, // Probe in HALF_OPEN state after 30 seconds
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## RFC 7234 Conditional Caching (`ETag` & `304 Not Modified`)
|
|
451
|
+
|
|
452
|
+
Automatically stores `ETag` and `Last-Modified` headers. On subsequent requests, sends `If-None-Match` and `If-Modified-Since`. When the server returns `304 Not Modified`, reuses the cached response body while updating response headers:
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
const api = createClient({
|
|
456
|
+
baseURL: 'https://api.example.com',
|
|
457
|
+
cache: {
|
|
458
|
+
enabled: true,
|
|
459
|
+
etag: true, // Enables conditional 304 validation
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Path Parameter Interpolation (`pathParams`)
|
|
467
|
+
|
|
468
|
+
Interpolate Express-style (`:param`) and OpenAPI-style (`{param}`) route variables with automated URI encoding:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
await api.get('/orgs/:orgId/projects/{projectId}/items/:itemId', {
|
|
472
|
+
pathParams: {
|
|
473
|
+
orgId: 'team-acme',
|
|
474
|
+
projectId: 'proj-123',
|
|
475
|
+
itemId: 'item#456', // Auto-encoded to item%23456
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Bearer Token Preset (`bearerToken`)
|
|
483
|
+
|
|
484
|
+
Avoid manual interceptors for token attachment. Pass a static string or an asynchronous token getter:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const api = createClient({
|
|
488
|
+
baseURL: 'https://api.example.com',
|
|
489
|
+
bearerToken: async () => await secureStorage.getItem('access_token'),
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## File Upload Helper (`api.upload`)
|
|
496
|
+
|
|
497
|
+
Upload files, Blobs, ArrayBuffers, or FormDatas with automatic boundary resolution and upload progress:
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
const avatarFile = fileInput.files[0];
|
|
501
|
+
|
|
502
|
+
const response = await api.upload('/users/avatar', avatarFile, {
|
|
503
|
+
fieldName: 'avatar',
|
|
504
|
+
fileName: 'photo.jpg',
|
|
505
|
+
extraFields: { userId: 123 },
|
|
506
|
+
onUploadProgress: (evt) => {
|
|
507
|
+
console.log(`Upload progress: ${Math.round((evt.progress ?? 0) * 100)}%`);
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## Response Unwrapping (`unwrap: true`)
|
|
515
|
+
|
|
516
|
+
Directly return `response.data` instead of the full envelope:
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
// Per-request unwrap:
|
|
520
|
+
const users = await api.get('/users', { unwrap: true });
|
|
521
|
+
|
|
522
|
+
// Or configure client-level unwrap:
|
|
523
|
+
const api = createClient({ unwrap: true });
|
|
524
|
+
const users = await api.get('/users'); // directly returns parsed data
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Diagnostic Debug Logger (`debug: true`)
|
|
530
|
+
|
|
531
|
+
Log outgoing requests with execution durations, status codes, and automatically redacted authentication tokens:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
const api = createClient({
|
|
535
|
+
baseURL: 'https://api.example.com',
|
|
536
|
+
debug: true, // or custom logger callback: (entry) => sendToDatadog(entry)
|
|
537
|
+
});
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## In-Flight Request Deduplication
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
When multiple UI components mount simultaneously and request the exact same endpoint (e.g. `GET /api/me`), standard clients fire multiple HTTP requests over the wire.
|
|
546
|
+
|
|
547
|
+
`react-native-fetch-client` coalesces them into **one single flight**:
|
|
548
|
+
|
|
549
|
+
```javascript
|
|
550
|
+
const api = createClient({
|
|
551
|
+
baseURL: 'https://api.example.com',
|
|
552
|
+
dedup: {
|
|
553
|
+
enabled: true, // Enable in-flight deduplication
|
|
554
|
+
methods: ['GET', 'HEAD'], // Methods eligible for deduplication
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Fire 3 simultaneous calls
|
|
559
|
+
const [user1, user2, user3] = await Promise.all([
|
|
560
|
+
api.get('/users/1'),
|
|
561
|
+
api.get('/users/1'),
|
|
562
|
+
api.get('/users/1'),
|
|
563
|
+
]);
|
|
564
|
+
|
|
565
|
+
console.log(user1.fromDedup); // false (Primary network socket)
|
|
566
|
+
console.log(user2.fromDedup); // true (Subscribed to in-flight flight)
|
|
567
|
+
console.log(user3.fromDedup); // true (Subscribed to in-flight flight)
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
> **Partial Cancellation Safety**: If `user2` calls `controller.abort()`, the underlying socket is **not** aborted; `user1` and `user3` still resolve normally.
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## Response Caching (LRU)
|
|
575
|
+
|
|
576
|
+
Built-in in-memory LRU cache with automatic mutation invalidation and deep-clone immutability:
|
|
577
|
+
|
|
578
|
+
```javascript
|
|
579
|
+
const api = createClient({
|
|
580
|
+
baseURL: 'https://api.example.com',
|
|
581
|
+
cache: {
|
|
582
|
+
enabled: true,
|
|
583
|
+
defaultTtl: 60000, // Cache responses for 60 seconds
|
|
584
|
+
maxSize: 100, // Maximum 100 items in memory (LRU eviction)
|
|
585
|
+
allowAuthenticated: false, // Prevents leaking user-specific data across accounts
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// 1. Initial request (hits live network)
|
|
590
|
+
const r1 = await api.get('/products');
|
|
591
|
+
console.log(r1.cached); // false
|
|
592
|
+
|
|
593
|
+
// 2. Subsequent request within 60s (returns from RAM in ~0ms)
|
|
594
|
+
const r2 = await api.get('/products');
|
|
595
|
+
console.log(r2.cached); // true
|
|
596
|
+
|
|
597
|
+
// 3. Automatic Invalidation on Mutation:
|
|
598
|
+
// Executing POST, PUT, PATCH, or DELETE automatically purges matching cached paths!
|
|
599
|
+
await api.post('/products', { name: 'New Item' });
|
|
600
|
+
|
|
601
|
+
// 4. Next GET fetches fresh data from server
|
|
602
|
+
const r3 = await api.get('/products');
|
|
603
|
+
console.log(r3.cached); // false
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Resilient Retry Engine
|
|
609
|
+
|
|
610
|
+
Automatically retries transient network errors and 5xx gateway errors with exponential backoff, full jitter, and RFC `Retry-After` header parsing:
|
|
611
|
+
|
|
612
|
+
```javascript
|
|
613
|
+
const api = createClient({
|
|
614
|
+
baseURL: 'https://api.example.com',
|
|
615
|
+
retry: {
|
|
616
|
+
attempts: 3, // Maximum 3 retry attempts
|
|
617
|
+
delay: 500, // Initial backoff delay: 500ms
|
|
618
|
+
maxDelay: 10000, // Cap delay at 10 seconds
|
|
619
|
+
backoff: 'exponential', // 'exponential' | 'linear' | 'constant'
|
|
620
|
+
jitter: true, // Applies full jitter to prevent thundering herds
|
|
621
|
+
statusCodes: [408, 429, 500, 502, 503, 504],
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
> **RFC Retry-After**: If the server responds with `429 Too Many Requests` and a header `Retry-After: 5` or `Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`, the client automatically pauses for the specified duration before retrying.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Auth & Token Refresh Mutex
|
|
631
|
+
|
|
632
|
+
Solves race conditions when multiple parallel requests receive 401s on expired access tokens:
|
|
633
|
+
|
|
634
|
+
```javascript
|
|
635
|
+
let currentAccessToken = 'initial_token';
|
|
636
|
+
|
|
637
|
+
const api = createClient({
|
|
638
|
+
baseURL: 'https://api.example.com',
|
|
639
|
+
auth: {
|
|
640
|
+
// 1. Returns current token (called before each request)
|
|
641
|
+
getToken: () => currentAccessToken,
|
|
642
|
+
|
|
643
|
+
// 2. Single-flight mutex refresh callback:
|
|
644
|
+
// If 10 requests fail with 401 simultaneously, refreshToken is called EXACTLY ONCE.
|
|
645
|
+
// The other 9 requests wait, then replay with the new token.
|
|
646
|
+
refreshToken: async () => {
|
|
647
|
+
const res = await fetch('https://api.example.com/oauth/refresh', { method: 'POST' });
|
|
648
|
+
const data = await res.json();
|
|
649
|
+
currentAccessToken = data.access_token;
|
|
650
|
+
return currentAccessToken;
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
headerName: 'Authorization',
|
|
654
|
+
tokenType: 'Bearer',
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
## Upload & Download Progress
|
|
662
|
+
|
|
663
|
+
Monitor real-time progress for both uploads and downloads:
|
|
664
|
+
|
|
665
|
+
### 1. Upload Progress (`onUploadProgress`)
|
|
666
|
+
|
|
667
|
+
```javascript
|
|
668
|
+
const fileBlob = new Blob(['sample data'], { type: 'text/plain' });
|
|
669
|
+
|
|
670
|
+
await api.post('/upload', fileBlob, {
|
|
671
|
+
onUploadProgress: (event) => {
|
|
672
|
+
const percent = Math.round((event.loaded / event.total) * 100);
|
|
673
|
+
console.log(`Uploaded ${event.loaded} / ${event.total} bytes (${percent}%)`);
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
### 2. Streaming Download Progress (`onDownloadProgress`)
|
|
679
|
+
|
|
680
|
+
Uses WHATWG `ReadableStream` to stream chunks without buffering the whole response in memory:
|
|
681
|
+
|
|
682
|
+
```javascript
|
|
683
|
+
await api.get('/downloads/big-file.zip', {
|
|
684
|
+
responseType: 'blob',
|
|
685
|
+
onDownloadProgress: (event) => {
|
|
686
|
+
console.log(`Received chunk: ${event.bytes} bytes. Total loaded: ${event.loaded} bytes`);
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
## Offline Request Queue
|
|
694
|
+
|
|
695
|
+
Queue mutations when the device is offline and replay them deterministically when connectivity returns:
|
|
696
|
+
|
|
697
|
+
```javascript
|
|
698
|
+
const { createClient, createOfflineQueue, MemoryStorageAdapter } = require('react-native-fetch-client');
|
|
699
|
+
|
|
700
|
+
const api = createClient({ baseURL: 'https://api.example.com' });
|
|
701
|
+
|
|
702
|
+
// Initialize queue with any storage engine (AsyncStorage, localStorage, IndexedDB)
|
|
703
|
+
const queue = createOfflineQueue({
|
|
704
|
+
client: api,
|
|
705
|
+
storage: new MemoryStorageAdapter(), // Or custom AsyncStorage adapter
|
|
706
|
+
maxQueueSize: 100, // Cap at 100 queued items
|
|
707
|
+
maxAttempts: 5, // Drop poison-pills after 5 attempts
|
|
708
|
+
idempotencyHeader: 'Idempotency-Key',// Automatically attaches UUID idempotency key
|
|
709
|
+
onReplaySuccess: (req) => {
|
|
710
|
+
console.log(`Synced offline mutation: ${req.method} ${req.url}`);
|
|
711
|
+
},
|
|
712
|
+
onPermanentFailure: (req, err) => {
|
|
713
|
+
console.error(`Mutation failed permanently: ${req.url}`, err.message);
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// 1. Enqueue action when network is lost:
|
|
718
|
+
await queue.enqueue({
|
|
719
|
+
url: '/notes',
|
|
720
|
+
method: 'POST',
|
|
721
|
+
body: { title: 'Drafted while offline' },
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// 2. Replay queue sequentially when back online:
|
|
725
|
+
const summary = await queue.process();
|
|
726
|
+
console.log(`Replay complete: ${summary.succeeded} succeeded, ${summary.remaining} remaining.`);
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
> **Financial Endpoint Guardrail**: Requests targeting `/pay`, `/checkout`, `/orders`, `/charge`, or `/billing` are strictly rejected from offline queuing by default to prevent unexpected delayed charges.
|
|
730
|
+
|
|
731
|
+
---
|
|
732
|
+
|
|
733
|
+
## Node.js Enterprise Proxies & Dispatcher
|
|
734
|
+
|
|
735
|
+
In Node.js 18+ environments, pass an Undici `ProxyAgent` to route requests through corporate forward proxies or custom mTLS agents:
|
|
736
|
+
|
|
737
|
+
```javascript
|
|
738
|
+
const { ProxyAgent } = require('undici');
|
|
739
|
+
const { createClient } = require('react-native-fetch-client');
|
|
740
|
+
|
|
741
|
+
const proxyClient = createClient({
|
|
742
|
+
baseURL: 'https://internal.corp',
|
|
743
|
+
dispatcher: new ProxyAgent('http://corporate-proxy.internal:8080'),
|
|
744
|
+
});
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
---
|
|
748
|
+
|
|
749
|
+
## Migration Guide (From Axios)
|
|
750
|
+
|
|
751
|
+
Switching from Axios takes only a few minutes. Most method signatures are identical:
|
|
752
|
+
|
|
753
|
+
| Axios | `react-native-fetch-client` | Notes |
|
|
754
|
+
| :--- | :--- | :--- |
|
|
755
|
+
| `axios.get(url, config)` | `client.get(url, config)` | Identical |
|
|
756
|
+
| `axios.post(url, data, config)` | `client.post(url, data, config)` | Identical |
|
|
757
|
+
| `res.data` | `res.data` | Identical |
|
|
758
|
+
| `axios.interceptors.request.use()` | `client.interceptors.request.use()` | Identical |
|
|
759
|
+
| `axios.isAxiosError(err)` | `isHttpError(err)` or `isFetchNativeError(err)` | Type-safe guards |
|
|
760
|
+
| `axios-retry` (plugin) | `client = createClient({ retry: {...} })` | Built-in |
|
|
761
|
+
| `axios-cache-interceptor` (plugin) | `client = createClient({ cache: {...} })` | Built-in |
|
|
762
|
+
|
|
763
|
+
---
|
|
764
|
+
|
|
765
|
+
## Testing Locally with Your Team
|
|
766
|
+
|
|
767
|
+
To test this library within your team before publishing to public npm:
|
|
768
|
+
|
|
769
|
+
### Option 1: Pack as a Local `.tgz` Tarball
|
|
770
|
+
```bash
|
|
771
|
+
# In react-native-fetch-client:
|
|
772
|
+
npm run build
|
|
773
|
+
npm pack
|
|
774
|
+
# Generates react-native-fetch-client-0.1.0.tgz
|
|
775
|
+
```
|
|
776
|
+
Share `react-native-fetch-client-0.1.0.tgz` with your teammate. In their project, they install:
|
|
777
|
+
```bash
|
|
778
|
+
npm install ./path/to/react-native-fetch-client-0.1.0.tgz
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### Option 2: Local `npm link`
|
|
782
|
+
```bash
|
|
783
|
+
# In react-native-fetch-client:
|
|
784
|
+
npm link
|
|
785
|
+
|
|
786
|
+
# In teammate's project:
|
|
787
|
+
npm link react-native-fetch-client
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
---
|
|
791
|
+
|
|
792
|
+
## Verification & Quality Gates
|
|
793
|
+
|
|
794
|
+
The codebase enforces strict quality checks:
|
|
795
|
+
|
|
796
|
+
```bash
|
|
797
|
+
npm run typecheck # Strict TypeScript check (0 errors)
|
|
798
|
+
npm test # 13 Vitest suites, 143 unit/integration tests (0 failures)
|
|
799
|
+
npm run lint # ESLint 10 verification
|
|
800
|
+
npm run build # Dual ESM & CJS build via tsup
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
## License
|
|
806
|
+
|
|
807
|
+
[MIT](LICENSE) © 2026
|