react-native-fetch-client 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,906 @@
1
- # Temporary Holding Version
1
+ # react-native-fetch-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+ [![TypeScript & JavaScript](https://img.shields.io/badge/Languages-TypeScript%20%7C%20JavaScript-blue)](https://www.typescriptlang.org/)
8
+ [![Runtime Dependencies](https://img.shields.io/badge/Dependencies-0-success.svg)](package.json)
9
+ [![Tests: 186 passing](https://img.shields.io/badge/Tests-186%20Passing-brightgreen.svg)](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](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/architecture-flow.md)** — Step-by-step trace through the 16-stage request pipeline.
18
+ * 🛠️ **[Core Features & Usage Guide](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/core-features-guide.md)** — Client setup, HTTP methods, path params, form encoding, safe requests, and unwrap.
19
+ * ⚡ **[Caching & Stale-While-Revalidate](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/caching-and-swr.md)** — In-memory LRU, persistent storage adapters (MMKV/AsyncStorage), ETag/304, and SWR.
20
+ * 🛡️ **[Resilience, Retries & Offline Queue](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/resilience-and-offline.md)** — Exponential backoff, jitter, circuit breakers, concurrency throttling, and offline replay.
21
+ * 🤖 **[Streaming, AI & File Uploads](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/streaming-and-uploads.md)** — SSE & AI LLM token streams, WHATWG streams, file uploads, and upload/download progress.
22
+ * 🔑 **[Authentication & Security](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/auth-and-security.md)** — Bearer presets, single-flight 401 refresh mutex, token rotation, and header redaction.
23
+ * 🔄 **[Auto-Pagination Helper](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/docs/auto-pagination.md)** — Traversal strategies (`page`, `offset`, `cursor`, `linkHeader`) and async generator streaming.
24
+ * 🧪 **[Testing & Mock Adapter Guide](https://github.com/arunkumarArjun/react-native-fetch-client/blob/main/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
+ ## Auto-Pagination Helper (`api.paginate`)
467
+
468
+ Traverse multi-page APIs seamlessly using async generators (`for await`) or collect all pages directly:
469
+
470
+ ```typescript
471
+ // 1. Stream items individually with for-await (Memory efficient!)
472
+ for await (const user of api.paginate<User>('/users', {
473
+ pagination: { type: 'page', pageParam: 'page', pageSize: 20 },
474
+ })) {
475
+ console.log(user.name);
476
+ }
477
+
478
+ // 2. Fetch all pages and flatten into an array in one call:
479
+ const allPosts = await api.paginate.all<Post>('/posts', {
480
+ pagination: { type: 'page', pageParam: '_page', limitParam: '_limit', limit: 10 },
481
+ });
482
+ console.log(`Fetched ${allPosts.length} posts across all pages!`);
483
+ ```
484
+
485
+ Supports 4 pagination strategies: `page`, `offset`, `cursor`, and RFC 5988/8288 standard `Link` headers.
486
+
487
+ ---
488
+
489
+ ## Persistent Storage Response Cache (`storage`)
490
+
491
+ Cache responses directly to mobile disk (MMKV, AsyncStorage, or localStorage) so data survives app restarts:
492
+
493
+ ```typescript
494
+ import { MMKV } from 'react-native-mmkv';
495
+
496
+ const mmkv = new MMKV({ id: 'http-cache' });
497
+
498
+ const api = createClient({
499
+ baseURL: 'https://api.example.com',
500
+ cache: {
501
+ enabled: true,
502
+ storage: {
503
+ getItem: (key) => mmkv.getString(key) ?? null,
504
+ setItem: (key, value) => mmkv.set(key, value),
505
+ removeItem: (key) => mmkv.delete(key),
506
+ },
507
+ defaultTtl: 300_000, // 5 minutes
508
+ },
509
+ });
510
+
511
+ // Automatically hydrates from MMKV on startup!
512
+ // Immediate cache hit on cold app boots (0ms latency):
513
+ const res = await api.get('/user/profile');
514
+ ```
515
+
516
+ ---
517
+
518
+ ## Stale-While-Revalidate (`strategy: 'stale-while-revalidate'`)
519
+
520
+ Return cached data instantly (**0ms latency**, `cached: true, stale: true`) to keep the UI buttery smooth, while quietly fetching the latest data from the network in the background:
521
+
522
+ ```typescript
523
+ const res = await api.get('/dashboard/stats', {
524
+ cache: {
525
+ strategy: 'stale-while-revalidate',
526
+ onRevalidated: (freshResponse) => {
527
+ console.log('Background sync complete! Updated data:', freshResponse.data);
528
+ updateUI(freshResponse.data);
529
+ },
530
+ },
531
+ });
532
+
533
+ console.log(res.stale); // true (Served immediately without network lag!)
534
+ ```
535
+
536
+ ---
537
+
538
+ ## Declarative Testing Mock Adapter (`createMockAdapter`)
539
+
540
+ Zero-dependency unit testing for your network calls. Stub routes, simulate network timeouts or offline errors, and assert call history:
541
+
542
+ ```typescript
543
+ import { createClient, createMockAdapter } from 'react-native-fetch-client';
544
+
545
+ const api = createClient({ baseURL: 'https://api.example.com' });
546
+ const mock = createMockAdapter(api);
547
+
548
+ // 1. Mock a route
549
+ mock.onGet('/users/active').reply(200, [{ id: 1, name: 'Alice' }]);
550
+
551
+ // 2. Simulate errors
552
+ mock.onPost('/billing/charge').replyWithTimeout();
553
+
554
+ // 3. Test & assert history
555
+ const res = await api.get('/users/active');
556
+ expect(res.data).toEqual([{ id: 1, name: 'Alice' }]);
557
+ expect(mock.history.get).toHaveLength(1);
558
+
559
+ // Clean up
560
+ mock.restore();
561
+ ```
562
+
563
+ ---
564
+
565
+ ## Path Parameter Interpolation (`pathParams`)
566
+
567
+ Interpolate Express-style (`:param`) and OpenAPI-style (`{param}`) route variables with automated URI encoding:
568
+
569
+ ```typescript
570
+ await api.get('/orgs/:orgId/projects/{projectId}/items/:itemId', {
571
+ pathParams: {
572
+ orgId: 'team-acme',
573
+ projectId: 'proj-123',
574
+ itemId: 'item#456', // Auto-encoded to item%23456
575
+ },
576
+ });
577
+ ```
578
+
579
+ ---
580
+
581
+ ## Bearer Token Preset (`bearerToken`)
582
+
583
+ Avoid manual interceptors for token attachment. Pass a static string or an asynchronous token getter:
584
+
585
+ ```typescript
586
+ const api = createClient({
587
+ baseURL: 'https://api.example.com',
588
+ bearerToken: async () => await secureStorage.getItem('access_token'),
589
+ });
590
+ ```
591
+
592
+ ---
593
+
594
+ ## File Upload Helper (`api.upload`)
595
+
596
+ Upload files, Blobs, ArrayBuffers, or FormDatas with automatic boundary resolution and upload progress:
597
+
598
+ ```typescript
599
+ const avatarFile = fileInput.files[0];
600
+
601
+ const response = await api.upload('/users/avatar', avatarFile, {
602
+ fieldName: 'avatar',
603
+ fileName: 'photo.jpg',
604
+ extraFields: { userId: 123 },
605
+ onUploadProgress: (evt) => {
606
+ console.log(`Upload progress: ${Math.round((evt.progress ?? 0) * 100)}%`);
607
+ },
608
+ });
609
+ ```
610
+
611
+ ---
612
+
613
+ ## Response Unwrapping (`unwrap: true`)
614
+
615
+ Directly return `response.data` instead of the full envelope:
616
+
617
+ ```typescript
618
+ // Per-request unwrap:
619
+ const users = await api.get('/users', { unwrap: true });
620
+
621
+ // Or configure client-level unwrap:
622
+ const api = createClient({ unwrap: true });
623
+ const users = await api.get('/users'); // directly returns parsed data
624
+ ```
625
+
626
+ ---
627
+
628
+ ## Diagnostic Debug Logger (`debug: true`)
629
+
630
+ Log outgoing requests with execution durations, status codes, and automatically redacted authentication tokens:
631
+
632
+ ```typescript
633
+ const api = createClient({
634
+ baseURL: 'https://api.example.com',
635
+ debug: true, // or custom logger callback: (entry) => sendToDatadog(entry)
636
+ });
637
+ ```
638
+
639
+ ---
640
+
641
+ ## In-Flight Request Deduplication
642
+
643
+
644
+ 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.
645
+
646
+ `react-native-fetch-client` coalesces them into **one single flight**:
647
+
648
+ ```javascript
649
+ const api = createClient({
650
+ baseURL: 'https://api.example.com',
651
+ dedup: {
652
+ enabled: true, // Enable in-flight deduplication
653
+ methods: ['GET', 'HEAD'], // Methods eligible for deduplication
654
+ },
655
+ });
656
+
657
+ // Fire 3 simultaneous calls
658
+ const [user1, user2, user3] = await Promise.all([
659
+ api.get('/users/1'),
660
+ api.get('/users/1'),
661
+ api.get('/users/1'),
662
+ ]);
663
+
664
+ console.log(user1.fromDedup); // false (Primary network socket)
665
+ console.log(user2.fromDedup); // true (Subscribed to in-flight flight)
666
+ console.log(user3.fromDedup); // true (Subscribed to in-flight flight)
667
+ ```
668
+
669
+ > **Partial Cancellation Safety**: If `user2` calls `controller.abort()`, the underlying socket is **not** aborted; `user1` and `user3` still resolve normally.
670
+
671
+ ---
672
+
673
+ ## Response Caching (LRU)
674
+
675
+ Built-in in-memory LRU cache with automatic mutation invalidation and deep-clone immutability:
676
+
677
+ ```javascript
678
+ const api = createClient({
679
+ baseURL: 'https://api.example.com',
680
+ cache: {
681
+ enabled: true,
682
+ defaultTtl: 60000, // Cache responses for 60 seconds
683
+ maxSize: 100, // Maximum 100 items in memory (LRU eviction)
684
+ allowAuthenticated: false, // Prevents leaking user-specific data across accounts
685
+ },
686
+ });
687
+
688
+ // 1. Initial request (hits live network)
689
+ const r1 = await api.get('/products');
690
+ console.log(r1.cached); // false
691
+
692
+ // 2. Subsequent request within 60s (returns from RAM in ~0ms)
693
+ const r2 = await api.get('/products');
694
+ console.log(r2.cached); // true
695
+
696
+ // 3. Automatic Invalidation on Mutation:
697
+ // Executing POST, PUT, PATCH, or DELETE automatically purges matching cached paths!
698
+ await api.post('/products', { name: 'New Item' });
699
+
700
+ // 4. Next GET fetches fresh data from server
701
+ const r3 = await api.get('/products');
702
+ console.log(r3.cached); // false
703
+ ```
704
+
705
+ ---
706
+
707
+ ## Resilient Retry Engine
708
+
709
+ Automatically retries transient network errors and 5xx gateway errors with exponential backoff, full jitter, and RFC `Retry-After` header parsing:
710
+
711
+ ```javascript
712
+ const api = createClient({
713
+ baseURL: 'https://api.example.com',
714
+ retry: {
715
+ attempts: 3, // Maximum 3 retry attempts
716
+ delay: 500, // Initial backoff delay: 500ms
717
+ maxDelay: 10000, // Cap delay at 10 seconds
718
+ backoff: 'exponential', // 'exponential' | 'linear' | 'constant'
719
+ jitter: true, // Applies full jitter to prevent thundering herds
720
+ statusCodes: [408, 429, 500, 502, 503, 504],
721
+ },
722
+ });
723
+ ```
724
+
725
+ > **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.
726
+
727
+ ---
728
+
729
+ ## Auth & Token Refresh Mutex
730
+
731
+ Solves race conditions when multiple parallel requests receive 401s on expired access tokens:
732
+
733
+ ```javascript
734
+ let currentAccessToken = 'initial_token';
735
+
736
+ const api = createClient({
737
+ baseURL: 'https://api.example.com',
738
+ auth: {
739
+ // 1. Returns current token (called before each request)
740
+ getToken: () => currentAccessToken,
741
+
742
+ // 2. Single-flight mutex refresh callback:
743
+ // If 10 requests fail with 401 simultaneously, refreshToken is called EXACTLY ONCE.
744
+ // The other 9 requests wait, then replay with the new token.
745
+ refreshToken: async () => {
746
+ const res = await fetch('https://api.example.com/oauth/refresh', { method: 'POST' });
747
+ const data = await res.json();
748
+ currentAccessToken = data.access_token;
749
+ return currentAccessToken;
750
+ },
751
+
752
+ headerName: 'Authorization',
753
+ tokenType: 'Bearer',
754
+ },
755
+ });
756
+ ```
757
+
758
+ ---
759
+
760
+ ## Upload & Download Progress
761
+
762
+ Monitor real-time progress for both uploads and downloads:
763
+
764
+ ### 1. Upload Progress (`onUploadProgress`)
765
+
766
+ ```javascript
767
+ const fileBlob = new Blob(['sample data'], { type: 'text/plain' });
768
+
769
+ await api.post('/upload', fileBlob, {
770
+ onUploadProgress: (event) => {
771
+ const percent = Math.round((event.loaded / event.total) * 100);
772
+ console.log(`Uploaded ${event.loaded} / ${event.total} bytes (${percent}%)`);
773
+ },
774
+ });
775
+ ```
776
+
777
+ ### 2. Streaming Download Progress (`onDownloadProgress`)
778
+
779
+ Uses WHATWG `ReadableStream` to stream chunks without buffering the whole response in memory:
780
+
781
+ ```javascript
782
+ await api.get('/downloads/big-file.zip', {
783
+ responseType: 'blob',
784
+ onDownloadProgress: (event) => {
785
+ console.log(`Received chunk: ${event.bytes} bytes. Total loaded: ${event.loaded} bytes`);
786
+ },
787
+ });
788
+ ```
789
+
790
+ ---
791
+
792
+ ## Offline Request Queue
793
+
794
+ Queue mutations when the device is offline and replay them deterministically when connectivity returns:
795
+
796
+ ```javascript
797
+ const { createClient, createOfflineQueue, MemoryStorageAdapter } = require('react-native-fetch-client');
798
+
799
+ const api = createClient({ baseURL: 'https://api.example.com' });
800
+
801
+ // Initialize queue with any storage engine (AsyncStorage, localStorage, IndexedDB)
802
+ const queue = createOfflineQueue({
803
+ client: api,
804
+ storage: new MemoryStorageAdapter(), // Or custom AsyncStorage adapter
805
+ maxQueueSize: 100, // Cap at 100 queued items
806
+ maxAttempts: 5, // Drop poison-pills after 5 attempts
807
+ idempotencyHeader: 'Idempotency-Key',// Automatically attaches UUID idempotency key
808
+ onReplaySuccess: (req) => {
809
+ console.log(`Synced offline mutation: ${req.method} ${req.url}`);
810
+ },
811
+ onPermanentFailure: (req, err) => {
812
+ console.error(`Mutation failed permanently: ${req.url}`, err.message);
813
+ },
814
+ });
815
+
816
+ // 1. Enqueue action when network is lost:
817
+ await queue.enqueue({
818
+ url: '/notes',
819
+ method: 'POST',
820
+ body: { title: 'Drafted while offline' },
821
+ });
822
+
823
+ // 2. Replay queue sequentially when back online:
824
+ const summary = await queue.process();
825
+ console.log(`Replay complete: ${summary.succeeded} succeeded, ${summary.remaining} remaining.`);
826
+ ```
827
+
828
+ > **Financial Endpoint Guardrail**: Requests targeting `/pay`, `/checkout`, `/orders`, `/charge`, or `/billing` are strictly rejected from offline queuing by default to prevent unexpected delayed charges.
829
+
830
+ ---
831
+
832
+ ## Node.js Enterprise Proxies & Dispatcher
833
+
834
+ In Node.js 18+ environments, pass an Undici `ProxyAgent` to route requests through corporate forward proxies or custom mTLS agents:
835
+
836
+ ```javascript
837
+ const { ProxyAgent } = require('undici');
838
+ const { createClient } = require('react-native-fetch-client');
839
+
840
+ const proxyClient = createClient({
841
+ baseURL: 'https://internal.corp',
842
+ dispatcher: new ProxyAgent('http://corporate-proxy.internal:8080'),
843
+ });
844
+ ```
845
+
846
+ ---
847
+
848
+ ## Migration Guide (From Axios)
849
+
850
+ Switching from Axios takes only a few minutes. Most method signatures are identical:
851
+
852
+ | Axios | `react-native-fetch-client` | Notes |
853
+ | :--- | :--- | :--- |
854
+ | `axios.get(url, config)` | `client.get(url, config)` | Identical |
855
+ | `axios.post(url, data, config)` | `client.post(url, data, config)` | Identical |
856
+ | `res.data` | `res.data` | Identical |
857
+ | `axios.interceptors.request.use()` | `client.interceptors.request.use()` | Identical |
858
+ | `axios.isAxiosError(err)` | `isHttpError(err)` or `isFetchNativeError(err)` | Type-safe guards |
859
+ | `axios-retry` (plugin) | `client = createClient({ retry: {...} })` | Built-in |
860
+ | `axios-cache-interceptor` (plugin) | `client = createClient({ cache: {...} })` | Built-in |
861
+
862
+ ---
863
+
864
+ ## Testing Locally with Your Team
865
+
866
+ To test this library within your team before publishing to public npm:
867
+
868
+ ### Option 1: Pack as a Local `.tgz` Tarball
869
+ ```bash
870
+ # In react-native-fetch-client:
871
+ npm run build
872
+ npm pack
873
+ # Generates react-native-fetch-client-0.1.0.tgz
874
+ ```
875
+ Share `react-native-fetch-client-0.1.0.tgz` with your teammate. In their project, they install:
876
+ ```bash
877
+ npm install ./path/to/react-native-fetch-client-0.1.0.tgz
878
+ ```
879
+
880
+ ### Option 2: Local `npm link`
881
+ ```bash
882
+ # In react-native-fetch-client:
883
+ npm link
884
+
885
+ # In teammate's project:
886
+ npm link react-native-fetch-client
887
+ ```
888
+
889
+ ---
890
+
891
+ ## Verification & Quality Gates
892
+
893
+ The codebase enforces strict quality checks:
894
+
895
+ ```bash
896
+ npm run typecheck # Strict TypeScript check (0 errors)
897
+ npm test # 13 Vitest suites, 143 unit/integration tests (0 failures)
898
+ npm run lint # ESLint 10 verification
899
+ npm run build # Dual ESM & CJS build via tsup
900
+ ```
901
+
902
+ ---
903
+
904
+ ## License
905
+
906
+ [MIT](LICENSE) © 2026