@bereasoftware/nexa 0.0.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/LICENSE +9 -0
- package/README.en.md +1288 -0
- package/README.md +1304 -0
- package/dist/nexa.cjs.js +1 -0
- package/dist/nexa.es.js +934 -0
- package/dist/nexa.iife.js +1 -0
- package/dist/nexa.umd.js +1 -0
- package/dist/types/http-client/http-client.d.ts +109 -0
- package/dist/types/http-client/index.d.ts +7 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/types/index.d.ts +142 -0
- package/dist/types/utils/index.d.ts +392 -0
- package/package.json +98 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h1 align="center">@bereasoftware/nexa</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
A modern, type-safe HTTP client that combines the power of <code>fetch</code> with the convenience of <code>axios</code> β built on SOLID principles.
|
|
5
|
+
</p>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="#tests"><img src="https://img.shields.io/badge/Tests-157_passing-brightgreen?style=for-the-badge" alt="Tests" /></a>
|
|
10
|
+
<a href="#test-coverage"><img src="https://img.shields.io/badge/Coverage-75.73%25-orange?style=for-the-badge" alt="Coverage" /></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@bereasoftware/nexa"><img src="https://img.shields.io/npm/v/@bereasoftware/nexa?style=for-the-badge" alt="NPM Version" /></a>
|
|
12
|
+
<a href="https://bundlephobia.com/package/@bereasoftware/nexa"><img src="https://img.shields.io/bundlephobia/minzip/@bereasoftware/nexa?label=Bundle&style=for-the-badge" alt="Bundle Size" /></a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/@bereasoftware/nexa"><img src="https://img.shields.io/npm/dm/@bereasoftware/nexa?style=for-the-badge" alt="NPM Downloads" /></a>
|
|
14
|
+
<img src="https://img.shields.io/badge/Node-18%2B-success?style=for-the-badge" alt="Node" />
|
|
15
|
+
<img src="https://img.shields.io/badge/TypeScript-5.x-3178C6?style=for-the-badge" alt="TypeScript" />
|
|
16
|
+
<img src="https://img.shields.io/badge/Dependencies-Zero-brightgreen?style=for-the-badge" alt="Dependencies" />
|
|
17
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="License" /></a>
|
|
18
|
+
<a href="https://github.com/Berea-Soft/nexa"><img src="https://img.shields.io/badge/github-Repository-blue?logo=github&style=for-the-badge" alt="Repository" /></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
> π **Documentation available in other languages:**
|
|
22
|
+
>
|
|
23
|
+
> - πͺπΈ [EspaΓ±ol](README.md)
|
|
24
|
+
> - π¬π§ **English** (this file - README.en.md)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Why Nexa?
|
|
29
|
+
|
|
30
|
+
| Feature | `fetch` | `axios` | **Nexa** |
|
|
31
|
+
| ------------------------------- | :-----: | :-----: | :------: |
|
|
32
|
+
| Zero dependencies | β
| β | β
|
|
|
33
|
+
| Type-safe errors (Result monad) | β | β | β
|
|
|
34
|
+
| Auto body serialization | β | β
| β
|
|
|
35
|
+
| Path parameter interpolation | β | β | β
|
|
|
36
|
+
| Retry strategies (pluggable) | β | β | β
|
|
|
37
|
+
| Built-in caching | β | β | β
|
|
|
38
|
+
| Request deduplication | β | β | β
|
|
|
39
|
+
| Download progress | β | β
| β
|
|
|
40
|
+
| Lifecycle hooks | β | β | β
|
|
|
41
|
+
| Concurrent request limiting | β | β | β
|
|
|
42
|
+
| Auto-pagination | β | β | β
|
|
|
43
|
+
| Smart polling | β | β | β
|
|
|
44
|
+
| Client extension (`.extend()`) | β | β
| β
|
|
|
45
|
+
| Interceptor disposal | β | β | β
|
|
|
46
|
+
| Middleware pipeline | β | β | β
|
|
|
47
|
+
| Plugin system | β | β | β
|
|
|
48
|
+
| Validators & transformers | β | β | β
|
|
|
49
|
+
| Response duration tracking | β | β | β
|
|
|
50
|
+
| Smart response type detection | β | β
| β
|
|
|
51
|
+
| Tree-shakeable | β
| β | β
|
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Table of Contents
|
|
56
|
+
|
|
57
|
+
- [Installation](#installation)
|
|
58
|
+
- [Quick Start](#quick-start)
|
|
59
|
+
- [Core Concepts](#core-concepts)
|
|
60
|
+
- [Result Monad (No try/catch)](#result-monad)
|
|
61
|
+
- [Creating a Client](#creating-a-client)
|
|
62
|
+
- [HTTP Methods](#http-methods)
|
|
63
|
+
- [Request Configuration](#request-configuration)
|
|
64
|
+
- [Path Parameters](#path-parameters)
|
|
65
|
+
- [Query Parameters](#query-parameters)
|
|
66
|
+
- [Auto Body Serialization](#auto-body-serialization)
|
|
67
|
+
- [Response Types](#response-types)
|
|
68
|
+
- [Timeout](#timeout)
|
|
69
|
+
- [Retry Strategies](#retry-strategies)
|
|
70
|
+
- [Inline Config](#inline-retry-config)
|
|
71
|
+
- [AggressiveRetry](#aggressiveretry)
|
|
72
|
+
- [ConservativeRetry](#conservativeretry)
|
|
73
|
+
- [CircuitBreakerRetry](#circuitbreakerretry)
|
|
74
|
+
- [Custom Strategy](#custom-retry-strategy)
|
|
75
|
+
- [Interceptors](#interceptors)
|
|
76
|
+
- [Request Interceptors](#request-interceptors)
|
|
77
|
+
- [Response Interceptors](#response-interceptors)
|
|
78
|
+
- [Interceptor Disposal](#interceptor-disposal)
|
|
79
|
+
- [Caching](#caching)
|
|
80
|
+
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
81
|
+
- [Download Progress](#download-progress)
|
|
82
|
+
- [Concurrent Request Limiting](#concurrent-request-limiting)
|
|
83
|
+
- [Client Extension](#client-extension)
|
|
84
|
+
- [Auto-Pagination](#auto-pagination)
|
|
85
|
+
- [Smart Polling](#smart-polling)
|
|
86
|
+
- [Request Cancellation](#request-cancellation)
|
|
87
|
+
- [Validators](#validators)
|
|
88
|
+
- [Transformers](#transformers)
|
|
89
|
+
- [Middleware Pipeline](#middleware-pipeline)
|
|
90
|
+
- [Plugin System](#plugin-system)
|
|
91
|
+
- [Streaming](#streaming)
|
|
92
|
+
- [Typed Generics](#typed-generics)
|
|
93
|
+
- [Error Handling](#error-handling)
|
|
94
|
+
- [API Reference](#api-reference)
|
|
95
|
+
- [Build Formats](#build-formats)
|
|
96
|
+
- [Development](#development)
|
|
97
|
+
- [License](#license)
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Installation
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm install @bereasoftware/nexa
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
yarn add @bereasoftware/nexa
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pnpm add @bereasoftware/nexa
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Quick Start
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { createHttpClient } from "@bereasoftware/nexa";
|
|
121
|
+
|
|
122
|
+
const client = createHttpClient({
|
|
123
|
+
baseURL: "https://api.example.com",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Type-safe, no try/catch needed
|
|
127
|
+
const result = await client.get<User>("/users/1");
|
|
128
|
+
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
console.log(result.value.data); // User
|
|
131
|
+
console.log(result.value.status); // 200
|
|
132
|
+
console.log(result.value.duration); // 42 (ms)
|
|
133
|
+
} else {
|
|
134
|
+
console.log(result.error.message); // "Request failed with status 404"
|
|
135
|
+
console.log(result.error.code); // "HTTP_ERROR"
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Core Concepts
|
|
142
|
+
|
|
143
|
+
### Result Monad
|
|
144
|
+
|
|
145
|
+
Nexa returns a `Result<T, E>` type instead of throwing exceptions. This eliminates the need for `try/catch` blocks and gives you full type safety on both success and error paths.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
You can also construct results manually:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { Ok, Err } from "@bereasoftware/nexa";
|
|
155
|
+
|
|
156
|
+
const success = Ok({ name: "John" }); // { ok: true, value: { name: 'John' } }
|
|
157
|
+
const failure = Err({ message: "Not found", code: "HTTP_ERROR" });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Every client method returns `Promise<Result<HttpResponse<T>, HttpErrorDetails>>`:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
const result = await client.get<User[]>('/users');
|
|
164
|
+
|
|
165
|
+
if (result.ok) {
|
|
166
|
+
// result.value is HttpResponse<User[]>
|
|
167
|
+
const users: User[] = result.value.data;
|
|
168
|
+
const status: number = result.value.status;
|
|
169
|
+
const duration: number = result.value.duration;
|
|
170
|
+
const headers: Headers = result.value.headers;
|
|
171
|
+
} else {
|
|
172
|
+
// result.error is HttpErrorDetails
|
|
173
|
+
const message: string = result.error.message;
|
|
174
|
+
const code: string = result.error.code; // 'HTTP_ERROR' | 'TIMEOUT' | 'NETWORK_ERROR' | 'ABORTED' | ...
|
|
175
|
+
const status?: number = result.error.status;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Creating a Client
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { createHttpClient } from "@bereasoftware/nexa";
|
|
183
|
+
|
|
184
|
+
const client = createHttpClient({
|
|
185
|
+
baseURL: "https://api.example.com",
|
|
186
|
+
defaultHeaders: { Authorization: "Bearer token123" },
|
|
187
|
+
defaultTimeout: 10000, // 10s (default: 30s)
|
|
188
|
+
validateStatus: (status) => status < 400, // Custom status validation
|
|
189
|
+
maxConcurrent: 5, // Max 5 simultaneous requests
|
|
190
|
+
defaultResponseType: "json", // 'json' | 'text' | 'blob' | 'auto' | ...
|
|
191
|
+
defaultHooks: {
|
|
192
|
+
onStart: (req) => console.log("Starting:", req.url),
|
|
193
|
+
onFinally: () => console.log("Done"),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Full `HttpClientConfig` options:**
|
|
199
|
+
|
|
200
|
+
| Option | Type | Default | Description |
|
|
201
|
+
| --------------------- | ----------------------------- | ---------------------------------------- | ------------------------------------------ |
|
|
202
|
+
| `baseURL` | `string` | `''` | Base URL prepended to all requests |
|
|
203
|
+
| `defaultHeaders` | `Record<string, string>` | `{ 'Content-Type': 'application/json' }` | Default headers for every request |
|
|
204
|
+
| `defaultTimeout` | `number` | `30000` | Default timeout in ms |
|
|
205
|
+
| `validateStatus` | `(status: number) => boolean` | `status >= 200 && status < 300` | Which HTTP statuses are considered success |
|
|
206
|
+
| `cacheStrategy` | `CacheStrategy` | `MemoryCache` | Custom cache implementation |
|
|
207
|
+
| `maxConcurrent` | `number` | `0` (unlimited) | Max concurrent requests |
|
|
208
|
+
| `defaultResponseType` | `ResponseType` | `'auto'` | Default response parsing strategy |
|
|
209
|
+
| `defaultHooks` | `RequestHooks` | `{}` | Default lifecycle hooks for all requests |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## HTTP Methods
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// GET
|
|
217
|
+
const result = await client.get<User>("/users/1");
|
|
218
|
+
|
|
219
|
+
// POST
|
|
220
|
+
const result = await client.post<User>("/users", {
|
|
221
|
+
name: "John",
|
|
222
|
+
email: "john@example.com",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// PUT
|
|
226
|
+
const result = await client.put<User>("/users/1", { name: "John Updated" });
|
|
227
|
+
|
|
228
|
+
// PATCH
|
|
229
|
+
const result = await client.patch<User>("/users/1", {
|
|
230
|
+
email: "new@example.com",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// DELETE
|
|
234
|
+
const result = await client.delete<void>("/users/1");
|
|
235
|
+
|
|
236
|
+
// HEAD (check resource existence)
|
|
237
|
+
const result = await client.head("/users/1");
|
|
238
|
+
|
|
239
|
+
// OPTIONS (CORS preflight, available methods)
|
|
240
|
+
const result = await client.options("/users");
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
All methods accept an optional config object as the last parameter:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
const result = await client.get<User>("/users/1", {
|
|
247
|
+
timeout: 5000,
|
|
248
|
+
headers: { "X-Custom": "value" },
|
|
249
|
+
cache: { enabled: true, ttlMs: 60000 },
|
|
250
|
+
retry: { maxAttempts: 3, backoffMs: 1000 },
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Request Configuration
|
|
257
|
+
|
|
258
|
+
### Path Parameters
|
|
259
|
+
|
|
260
|
+
Nexa supports `:param` style path interpolation with automatic URI encoding:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
const result = await client.get<User>("/users/:id/posts/:postId", {
|
|
264
|
+
params: { id: 42, postId: "hello world" },
|
|
265
|
+
});
|
|
266
|
+
// β GET /users/42/posts/hello%20world
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Query Parameters
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
const result = await client.get<User[]>("/users", {
|
|
273
|
+
query: { page: 1, limit: 20, active: true },
|
|
274
|
+
});
|
|
275
|
+
// β GET /users?page=1&limit=20&active=true
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Auto Body Serialization
|
|
279
|
+
|
|
280
|
+
Nexa automatically detects and serializes the request body:
|
|
281
|
+
|
|
282
|
+
| Body Type | Serialization | Content-Type |
|
|
283
|
+
| ------------------ | ------------------ | ----------------------------------- |
|
|
284
|
+
| `object` / `array` | `JSON.stringify()` | `application/json` |
|
|
285
|
+
| `string` | Passed as-is | `text/plain` |
|
|
286
|
+
| `FormData` | Passed as-is | Auto (multipart boundary) |
|
|
287
|
+
| `URLSearchParams` | Passed as-is | `application/x-www-form-urlencoded` |
|
|
288
|
+
| `Blob` | Passed as-is | Blob's type |
|
|
289
|
+
| `ArrayBuffer` | Passed as-is | `application/octet-stream` |
|
|
290
|
+
| `ReadableStream` | Passed as-is | `application/octet-stream` |
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// JSON (automatic)
|
|
294
|
+
await client.post("/users", { name: "John" });
|
|
295
|
+
|
|
296
|
+
// FormData (automatic content-type with boundary)
|
|
297
|
+
const form = new FormData();
|
|
298
|
+
form.append("file", fileBlob);
|
|
299
|
+
await client.post("/upload", form);
|
|
300
|
+
|
|
301
|
+
// URL-encoded
|
|
302
|
+
await client.post(
|
|
303
|
+
"/login",
|
|
304
|
+
new URLSearchParams({ user: "john", pass: "secret" }),
|
|
305
|
+
);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Response Types
|
|
309
|
+
|
|
310
|
+
Control how the response body is parsed:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// Auto-detect based on Content-Type header (default)
|
|
314
|
+
const result = await client.get("/data", { responseType: "auto" });
|
|
315
|
+
|
|
316
|
+
// Force JSON parsing
|
|
317
|
+
const result = await client.get<User>("/user", { responseType: "json" });
|
|
318
|
+
|
|
319
|
+
// Get raw text
|
|
320
|
+
const result = await client.get<string>("/page", { responseType: "text" });
|
|
321
|
+
|
|
322
|
+
// Download as Blob
|
|
323
|
+
const result = await client.get<Blob>("/file.pdf", { responseType: "blob" });
|
|
324
|
+
|
|
325
|
+
// Get ArrayBuffer
|
|
326
|
+
const result = await client.get<ArrayBuffer>("/binary", {
|
|
327
|
+
responseType: "arrayBuffer",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Get FormData
|
|
331
|
+
const result = await client.get<FormData>("/form", {
|
|
332
|
+
responseType: "formData",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Get ReadableStream (for manual streaming)
|
|
336
|
+
const result = await client.get<ReadableStream>("/stream", {
|
|
337
|
+
responseType: "stream",
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Auto-detection logic:** `application/json` β JSON, `text/*` β text, `multipart/form-data` β FormData, `application/octet-stream` / `image/*` / `audio/*` / `video/*` β Blob, fallback β try JSON then text.
|
|
342
|
+
|
|
343
|
+
### Timeout
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
// Per-request timeout
|
|
347
|
+
const result = await client.get("/slow-endpoint", { timeout: 5000 });
|
|
348
|
+
|
|
349
|
+
// Timeout produces a specific error code
|
|
350
|
+
if (!result.ok && result.error.code === "TIMEOUT") {
|
|
351
|
+
console.log("Request timed out");
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Retry Strategies
|
|
358
|
+
|
|
359
|
+
### Inline Retry Config
|
|
360
|
+
|
|
361
|
+
Simple retry with exponential backoff:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
const result = await client.get("/unstable-api", {
|
|
365
|
+
retry: { maxAttempts: 3, backoffMs: 1000 },
|
|
366
|
+
});
|
|
367
|
+
// Retries up to 3 times with exponential backoff + jitter
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### AggressiveRetry
|
|
371
|
+
|
|
372
|
+
Retries all errors up to max attempts with minimal delay:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { AggressiveRetry } from "@bereasoftware/nexa";
|
|
376
|
+
|
|
377
|
+
const result = await client.get("/api", {
|
|
378
|
+
retry: new AggressiveRetry(5), // 5 attempts, 50ms * attempt delay
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### ConservativeRetry
|
|
383
|
+
|
|
384
|
+
Only retries on specific HTTP status codes (408, 429, 500, 502, 503, 504) and timeouts:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import { ConservativeRetry } from "@bereasoftware/nexa";
|
|
388
|
+
|
|
389
|
+
const result = await client.get("/api", {
|
|
390
|
+
retry: new ConservativeRetry(3), // 3 attempts, exponential backoff capped at 10s
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### CircuitBreakerRetry
|
|
395
|
+
|
|
396
|
+
Fail-fast pattern β stops retrying after a threshold of failures:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import { CircuitBreakerRetry } from "@bereasoftware/nexa";
|
|
400
|
+
|
|
401
|
+
const breaker = new CircuitBreakerRetry(
|
|
402
|
+
3, // maxAttempts per request
|
|
403
|
+
5, // failureThreshold before circuit opens
|
|
404
|
+
60000, // resetTimeMs β circuit resets after 60s
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const result = await client.get("/api", { retry: breaker });
|
|
408
|
+
|
|
409
|
+
// Reset the circuit manually
|
|
410
|
+
breaker.reset();
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Custom Retry Strategy
|
|
414
|
+
|
|
415
|
+
Implement the `RetryStrategy` interface:
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
import type { RetryStrategy, HttpErrorDetails } from "@bereasoftware/nexa";
|
|
419
|
+
|
|
420
|
+
const customRetry: RetryStrategy = {
|
|
421
|
+
shouldRetry(attempt: number, error: HttpErrorDetails): boolean {
|
|
422
|
+
// Only retry network errors and 503
|
|
423
|
+
return (
|
|
424
|
+
(error.code === "NETWORK_ERROR" || error.status === 503) && attempt < 5
|
|
425
|
+
);
|
|
426
|
+
},
|
|
427
|
+
delayMs(attempt: number): number {
|
|
428
|
+
// Linear backoff: 500ms, 1000ms, 1500ms...
|
|
429
|
+
return attempt * 500;
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const result = await client.get("/api", { retry: customRetry });
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Interceptors
|
|
439
|
+
|
|
440
|
+
### Request Interceptors
|
|
441
|
+
|
|
442
|
+
Modify requests before they are sent:
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
client.addRequestInterceptor({
|
|
446
|
+
onRequest(request) {
|
|
447
|
+
// Add auth token to every request
|
|
448
|
+
return {
|
|
449
|
+
...request,
|
|
450
|
+
headers: {
|
|
451
|
+
...request.headers,
|
|
452
|
+
Authorization: `Bearer ${getToken()}`,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Response Interceptors
|
|
460
|
+
|
|
461
|
+
Transform responses or handle errors globally:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
client.addResponseInterceptor({
|
|
465
|
+
onResponse(response) {
|
|
466
|
+
// Log all successful responses
|
|
467
|
+
console.log(
|
|
468
|
+
`[${response.status}] ${response.request.url} (${response.duration}ms)`,
|
|
469
|
+
);
|
|
470
|
+
return response;
|
|
471
|
+
},
|
|
472
|
+
onError(error) {
|
|
473
|
+
// Handle 401 globally
|
|
474
|
+
if (error.status === 401) {
|
|
475
|
+
redirectToLogin();
|
|
476
|
+
}
|
|
477
|
+
return error;
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Interceptor Disposal
|
|
483
|
+
|
|
484
|
+
Both `addRequestInterceptor` and `addResponseInterceptor` return a disposer function to remove the interceptor:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const dispose = client.addRequestInterceptor({
|
|
488
|
+
onRequest(request) {
|
|
489
|
+
return { ...request, headers: { ...request.headers, "X-Temp": "value" } };
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Later: remove the interceptor
|
|
494
|
+
dispose();
|
|
495
|
+
|
|
496
|
+
// Or clear all interceptors
|
|
497
|
+
client.clearInterceptors();
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Caching
|
|
503
|
+
|
|
504
|
+
Built-in in-memory cache with TTL support. Only caches GET requests:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
const result = await client.get<User>("/users/1", {
|
|
508
|
+
cache: { enabled: true, ttlMs: 60000 }, // Cache for 1 minute
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Second call returns cached response instantly
|
|
512
|
+
const cached = await client.get<User>("/users/1", {
|
|
513
|
+
cache: { enabled: true, ttlMs: 60000 },
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
**Custom cache implementation:**
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
import type { CacheStrategy } from "@bereasoftware/nexa";
|
|
521
|
+
|
|
522
|
+
const redisCache: CacheStrategy = {
|
|
523
|
+
get(key: string) {
|
|
524
|
+
return redis.get(key);
|
|
525
|
+
},
|
|
526
|
+
set(key: string, value: unknown, ttlMs?: number) {
|
|
527
|
+
redis.set(key, value, "PX", ttlMs);
|
|
528
|
+
},
|
|
529
|
+
has(key: string) {
|
|
530
|
+
return redis.exists(key);
|
|
531
|
+
},
|
|
532
|
+
clear() {
|
|
533
|
+
redis.flushdb();
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const client = createHttpClient({ cacheStrategy: redisCache });
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Lifecycle Hooks
|
|
543
|
+
|
|
544
|
+
Monitor the full request lifecycle:
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
const result = await client.get<User>("/users/1", {
|
|
548
|
+
hooks: {
|
|
549
|
+
onStart(request) {
|
|
550
|
+
console.log("Starting request to:", request.url);
|
|
551
|
+
},
|
|
552
|
+
onSuccess(response) {
|
|
553
|
+
console.log("Success:", response.status, `(${response.duration}ms)`);
|
|
554
|
+
},
|
|
555
|
+
onError(error) {
|
|
556
|
+
console.error("Failed:", error.message, error.code);
|
|
557
|
+
},
|
|
558
|
+
onRetry(attempt, error) {
|
|
559
|
+
console.warn(`Retry #${attempt}:`, error.message);
|
|
560
|
+
},
|
|
561
|
+
onFinally() {
|
|
562
|
+
console.log("Request complete (success or failure)");
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Default hooks can be set at the client level:
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
const client = createHttpClient({
|
|
572
|
+
defaultHooks: {
|
|
573
|
+
onError: (error) => reportToSentry(error),
|
|
574
|
+
onFinally: () => hideLoadingSpinner(),
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Download Progress
|
|
582
|
+
|
|
583
|
+
Track download progress with a callback:
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
const result = await client.get<Blob>("/large-file.zip", {
|
|
587
|
+
responseType: "blob",
|
|
588
|
+
onDownloadProgress(event) {
|
|
589
|
+
console.log(
|
|
590
|
+
`Downloaded: ${event.percent}% (${event.loaded}/${event.total} bytes)`,
|
|
591
|
+
);
|
|
592
|
+
updateProgressBar(event.percent);
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The `ProgressEvent` interface:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
interface ProgressEvent {
|
|
601
|
+
loaded: number; // Bytes downloaded so far
|
|
602
|
+
total: number; // Total bytes (from Content-Length header)
|
|
603
|
+
percent: number; // 0-100
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Concurrent Request Limiting
|
|
610
|
+
|
|
611
|
+
Limit the number of simultaneous requests to avoid overwhelming a server:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
const client = createHttpClient({
|
|
615
|
+
baseURL: "https://api.example.com",
|
|
616
|
+
maxConcurrent: 3, // Only 3 requests at a time
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Fire 10 requests β only 3 run simultaneously, rest queue automatically
|
|
620
|
+
const results = await Promise.all(urls.map((url) => client.get(url)));
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Check queue status:
|
|
624
|
+
|
|
625
|
+
```typescript
|
|
626
|
+
console.log(client.queueStats);
|
|
627
|
+
// { active: 3, pending: 7 }
|
|
628
|
+
|
|
629
|
+
console.log(client.activeRequests);
|
|
630
|
+
// 3
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## Client Extension
|
|
636
|
+
|
|
637
|
+
Create child clients that inherit configuration and interceptors:
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
const baseClient = createHttpClient({
|
|
641
|
+
baseURL: "https://api.example.com",
|
|
642
|
+
defaultHeaders: { "X-App": "MyApp" },
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
baseClient.addRequestInterceptor({
|
|
646
|
+
onRequest(req) {
|
|
647
|
+
return {
|
|
648
|
+
...req,
|
|
649
|
+
headers: { ...req.headers, Authorization: "Bearer token" },
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Child inherits baseURL, headers, interceptors β and adds version header
|
|
655
|
+
const v2Client = baseClient.extend({
|
|
656
|
+
defaultHeaders: { "X-API-Version": "2" },
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// v2Client has headers: { 'X-App': 'MyApp', 'X-API-Version': '2' }
|
|
660
|
+
// v2Client also has the auth interceptor from baseClient
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
## Auto-Pagination
|
|
666
|
+
|
|
667
|
+
Iterate through paginated APIs with async generators:
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
interface PageResponse {
|
|
671
|
+
items: User[];
|
|
672
|
+
nextCursor: string | null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for await (const users of client.paginate<PageResponse>("/users", {
|
|
676
|
+
getItems: (data) => data.items,
|
|
677
|
+
getNextPage: (data, config) =>
|
|
678
|
+
data.nextCursor
|
|
679
|
+
? {
|
|
680
|
+
...config,
|
|
681
|
+
query: { ...(config.query as any), cursor: data.nextCursor },
|
|
682
|
+
}
|
|
683
|
+
: null,
|
|
684
|
+
})) {
|
|
685
|
+
console.log("Page with", users.length, "users");
|
|
686
|
+
// Process each page of users
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Pagination stops automatically when `getNextPage` returns `null` or a request fails.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## Smart Polling
|
|
695
|
+
|
|
696
|
+
Poll an endpoint until a condition is met:
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
interface Job {
|
|
700
|
+
id: string;
|
|
701
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
702
|
+
result?: string;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const result = await client.poll<Job>("/jobs/abc123", {
|
|
706
|
+
intervalMs: 2000, // Poll every 2 seconds
|
|
707
|
+
maxAttempts: 30, // Give up after 30 attempts (0 = unlimited)
|
|
708
|
+
until: (job) => job.status === "completed" || job.status === "failed",
|
|
709
|
+
onPoll: (job, attempt) => {
|
|
710
|
+
console.log(`Attempt ${attempt}: ${job.status}`);
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
if (result.ok) {
|
|
715
|
+
console.log("Job finished:", result.value.data.result);
|
|
716
|
+
} else if (result.error.code === "POLL_EXHAUSTED") {
|
|
717
|
+
console.log("Polling timed out");
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## Request Cancellation
|
|
724
|
+
|
|
725
|
+
Cancel all pending requests:
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
// Start several requests
|
|
729
|
+
const promise1 = client.get("/slow-1");
|
|
730
|
+
const promise2 = client.get("/slow-2");
|
|
731
|
+
|
|
732
|
+
// Cancel everything
|
|
733
|
+
client.cancelAll();
|
|
734
|
+
|
|
735
|
+
// Or use AbortSignal for individual requests
|
|
736
|
+
const controller = new AbortController();
|
|
737
|
+
const result = client.get("/data", { signal: controller.signal });
|
|
738
|
+
controller.abort();
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
## Validators
|
|
744
|
+
|
|
745
|
+
Validate response data before it reaches your code:
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
import {
|
|
749
|
+
createSchemaValidator,
|
|
750
|
+
createRequiredFieldsValidator,
|
|
751
|
+
validatorIsArray,
|
|
752
|
+
validatorIsObject,
|
|
753
|
+
} from "@bereasoftware/nexa";
|
|
754
|
+
|
|
755
|
+
// Schema validator
|
|
756
|
+
const userValidator = createSchemaValidator<User>({
|
|
757
|
+
id: (v) => typeof v === "number",
|
|
758
|
+
name: (v) => typeof v === "string" && (v as string).length > 0,
|
|
759
|
+
email: (v) => typeof v === "string" && (v as string).includes("@"),
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const result = await client.get<User>("/users/1", {
|
|
763
|
+
validate: userValidator,
|
|
764
|
+
});
|
|
765
|
+
// If validation fails: result.error.code === 'VALIDATION_ERROR'
|
|
766
|
+
|
|
767
|
+
// Required fields validator
|
|
768
|
+
const result = await client.get("/api/data", {
|
|
769
|
+
validate: createRequiredFieldsValidator(["id", "name", "createdAt"]),
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Array validator
|
|
773
|
+
const result = await client.get("/users", {
|
|
774
|
+
validate: validatorIsArray,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Object validator
|
|
778
|
+
const result = await client.get("/user/1", {
|
|
779
|
+
validate: validatorIsObject,
|
|
780
|
+
});
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
## Transformers
|
|
786
|
+
|
|
787
|
+
Transform response data after parsing:
|
|
788
|
+
|
|
789
|
+
```typescript
|
|
790
|
+
import {
|
|
791
|
+
transformSnakeToCamel,
|
|
792
|
+
transformCamelToSnake,
|
|
793
|
+
transformFlatten,
|
|
794
|
+
createProjectionTransformer,
|
|
795
|
+
createWrapperTransformer,
|
|
796
|
+
} from "@bereasoftware/nexa";
|
|
797
|
+
|
|
798
|
+
// Convert snake_case API responses to camelCase
|
|
799
|
+
const result = await client.get("/users/1", {
|
|
800
|
+
transform: transformSnakeToCamel,
|
|
801
|
+
});
|
|
802
|
+
// { first_name: 'John' } β { firstName: 'John' }
|
|
803
|
+
|
|
804
|
+
// Convert camelCase to snake_case (for sending data)
|
|
805
|
+
const result = await client.get("/data", {
|
|
806
|
+
transform: transformCamelToSnake,
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Flatten nested objects
|
|
810
|
+
const result = await client.get("/nested", {
|
|
811
|
+
transform: transformFlatten,
|
|
812
|
+
});
|
|
813
|
+
// { user: { name: 'John' } } β { 'user.name': 'John' }
|
|
814
|
+
|
|
815
|
+
// Pick specific fields
|
|
816
|
+
const result = await client.get("/users/1", {
|
|
817
|
+
transform: createProjectionTransformer(["id", "name"]),
|
|
818
|
+
});
|
|
819
|
+
// Only keeps { id, name } from the response
|
|
820
|
+
|
|
821
|
+
// Wrap data in a container
|
|
822
|
+
const result = await client.get("/items", {
|
|
823
|
+
transform: createWrapperTransformer("data"),
|
|
824
|
+
});
|
|
825
|
+
// [1, 2, 3] β { data: [1, 2, 3] }
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
---
|
|
829
|
+
|
|
830
|
+
## Middleware Pipeline
|
|
831
|
+
|
|
832
|
+
Express/Koa-style middleware pipeline for advanced request processing:
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
import {
|
|
836
|
+
createPipeline,
|
|
837
|
+
createCacheMiddleware,
|
|
838
|
+
createDedupeMiddleware,
|
|
839
|
+
createStreamingMiddleware,
|
|
840
|
+
type HttpContext,
|
|
841
|
+
type Middleware,
|
|
842
|
+
} from "@bereasoftware/nexa";
|
|
843
|
+
|
|
844
|
+
// Create custom middleware
|
|
845
|
+
const loggingMiddleware: Middleware<HttpContext> = async (ctx, next) => {
|
|
846
|
+
console.log(`β ${ctx.request.method} ${ctx.request.url}`);
|
|
847
|
+
const start = Date.now();
|
|
848
|
+
await next();
|
|
849
|
+
console.log(`β ${ctx.response.status} (${Date.now() - start}ms)`);
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
const authMiddleware: Middleware<HttpContext> = async (ctx, next) => {
|
|
853
|
+
ctx.request.headers["Authorization"] = `Bearer ${getToken()}`;
|
|
854
|
+
await next();
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// Build and execute pipeline
|
|
858
|
+
const pipeline = createPipeline([
|
|
859
|
+
loggingMiddleware,
|
|
860
|
+
authMiddleware,
|
|
861
|
+
createCacheMiddleware({ ttlMs: 30000 }),
|
|
862
|
+
createDedupeMiddleware(),
|
|
863
|
+
]);
|
|
864
|
+
|
|
865
|
+
const ctx: HttpContext = {
|
|
866
|
+
request: { method: "GET", url: "/users", headers: {} },
|
|
867
|
+
response: { status: 0, headers: {} },
|
|
868
|
+
state: {},
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
await pipeline(ctx);
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
**Pre-built middleware:**
|
|
875
|
+
|
|
876
|
+
| Middleware | Description |
|
|
877
|
+
| ------------------------------------- | -------------------------------------------- |
|
|
878
|
+
| `createCacheMiddleware(options?)` | Caches GET responses with TTL |
|
|
879
|
+
| `cacheMiddleware` | Pre-configured cache (60s TTL) |
|
|
880
|
+
| `createDedupeMiddleware(options?)` | Deduplicates concurrent identical requests |
|
|
881
|
+
| `dedupeMiddleware` | Pre-configured deduplication for GET |
|
|
882
|
+
| `createStreamingMiddleware(options?)` | Handles streaming responses with progress |
|
|
883
|
+
| `streamingMiddleware` | Pre-configured streaming with console output |
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
887
|
+
## Plugin System
|
|
888
|
+
|
|
889
|
+
Extend Nexa with a plugin architecture:
|
|
890
|
+
|
|
891
|
+
```typescript
|
|
892
|
+
import {
|
|
893
|
+
PluginManager,
|
|
894
|
+
LoggerPlugin,
|
|
895
|
+
MetricsPlugin,
|
|
896
|
+
CachePlugin,
|
|
897
|
+
DedupePlugin,
|
|
898
|
+
} from "@bereasoftware/nexa";
|
|
899
|
+
|
|
900
|
+
const manager = new PluginManager();
|
|
901
|
+
|
|
902
|
+
// Register plugins
|
|
903
|
+
manager
|
|
904
|
+
.register(LoggerPlugin)
|
|
905
|
+
.register(new MetricsPlugin())
|
|
906
|
+
.register(new CachePlugin(30000)) // 30s TTL
|
|
907
|
+
.register(new DedupePlugin());
|
|
908
|
+
|
|
909
|
+
// Listen to events
|
|
910
|
+
manager.on("request:start", (url) => console.log("Request to:", url));
|
|
911
|
+
manager.on("request:success", (url, status) =>
|
|
912
|
+
console.log("Success:", url, status),
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Get metrics
|
|
916
|
+
const metrics = (
|
|
917
|
+
manager.getPlugins().find((p) => p.name === "metrics") as MetricsPlugin
|
|
918
|
+
).getMetrics();
|
|
919
|
+
console.log(metrics); // { requests: 10, errors: 1, totalTime: 4200, avgTime: 420 }
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Create custom plugins:**
|
|
923
|
+
|
|
924
|
+
```typescript
|
|
925
|
+
import type { Plugin } from "@bereasoftware/nexa";
|
|
926
|
+
|
|
927
|
+
const rateLimitPlugin: Plugin = {
|
|
928
|
+
name: "rate-limit",
|
|
929
|
+
setup(client) {
|
|
930
|
+
// Add rate limiting middleware, event listeners, etc.
|
|
931
|
+
const manager = client as PluginManager;
|
|
932
|
+
manager.on("request:start", () => {
|
|
933
|
+
// Custom rate limiting logic
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
manager.register(rateLimitPlugin);
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
943
|
+
## Streaming
|
|
944
|
+
|
|
945
|
+
Handle large files and streaming responses:
|
|
946
|
+
|
|
947
|
+
```typescript
|
|
948
|
+
import { handleStream, streamToFile } from "@bereasoftware/nexa";
|
|
949
|
+
|
|
950
|
+
// Manual stream processing
|
|
951
|
+
const response = await fetch("https://example.com/large-file");
|
|
952
|
+
const data = await handleStream(response, {
|
|
953
|
+
onChunk(chunk) {
|
|
954
|
+
console.log("Received chunk:", chunk.length, "bytes");
|
|
955
|
+
},
|
|
956
|
+
onProgress(loaded, total) {
|
|
957
|
+
console.log(`Progress: ${Math.round((loaded / total) * 100)}%`);
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Download stream to file
|
|
962
|
+
const response = await fetch("https://example.com/data.csv");
|
|
963
|
+
await streamToFile(response, "output.csv");
|
|
964
|
+
// Works in both Node.js (fs.writeFile) and browser (Blob download)
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## Typed Generics
|
|
970
|
+
|
|
971
|
+
Advanced type-safe utilities for API client design:
|
|
972
|
+
|
|
973
|
+
### Typed API Client
|
|
974
|
+
|
|
975
|
+
```typescript
|
|
976
|
+
import { createTypedApiClient, type ApiEndpoint } from "@bereasoftware/nexa";
|
|
977
|
+
|
|
978
|
+
// Define your API schema with full types
|
|
979
|
+
interface UserApi {
|
|
980
|
+
getUser: ApiEndpoint<void, User>;
|
|
981
|
+
createUser: ApiEndpoint<CreateUserDto, User>;
|
|
982
|
+
listUsers: ApiEndpoint<void, User[]>;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const api = createTypedApiClient<UserApi>({
|
|
986
|
+
getUser: { method: "GET", path: "/users/1", response: {} as User },
|
|
987
|
+
createUser: { method: "POST", path: "/users", response: {} as User },
|
|
988
|
+
listUsers: { method: "GET", path: "/users", response: [] as User[] },
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// Fully typed request β knows input and output types
|
|
992
|
+
const user = await api.request(client, "getUser");
|
|
993
|
+
const newUser = await api.request(client, "createUser", {
|
|
994
|
+
name: "Ella",
|
|
995
|
+
email: "ella@example.com",
|
|
996
|
+
});
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
### Branded Types
|
|
1000
|
+
|
|
1001
|
+
```typescript
|
|
1002
|
+
import {
|
|
1003
|
+
createUrl,
|
|
1004
|
+
createApiUrl,
|
|
1005
|
+
type Url,
|
|
1006
|
+
type ApiUrl,
|
|
1007
|
+
type FileUrl,
|
|
1008
|
+
} from "@bereasoftware/nexa";
|
|
1009
|
+
|
|
1010
|
+
// Branded URLs prevent mixing different URL types at compile time
|
|
1011
|
+
const apiUrl: ApiUrl = createApiUrl("/users/1");
|
|
1012
|
+
const genericUrl: Url = createUrl("https://example.com");
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Type Guards
|
|
1016
|
+
|
|
1017
|
+
```typescript
|
|
1018
|
+
import { createTypeGuard } from "@bereasoftware/nexa";
|
|
1019
|
+
|
|
1020
|
+
interface User {
|
|
1021
|
+
id: number;
|
|
1022
|
+
name: string;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const ensureUser = createTypeGuard<User>(
|
|
1026
|
+
(value): value is User =>
|
|
1027
|
+
typeof value === "object" &&
|
|
1028
|
+
value !== null &&
|
|
1029
|
+
"id" in value &&
|
|
1030
|
+
"name" in value,
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
const user = ensureUser(unknownData); // throws TypeError if invalid
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
### Observable Pattern
|
|
1037
|
+
|
|
1038
|
+
```typescript
|
|
1039
|
+
import { TypedObservable } from "@bereasoftware/nexa";
|
|
1040
|
+
|
|
1041
|
+
const stream = new TypedObservable<User>();
|
|
1042
|
+
|
|
1043
|
+
const sub = stream.subscribe(
|
|
1044
|
+
(user) => console.log("User:", user.name),
|
|
1045
|
+
(err) => console.error("Error:", err),
|
|
1046
|
+
() => console.log("Complete"),
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Chainable operators
|
|
1050
|
+
const names = stream.filter((user) => user.active).map((user) => user.name);
|
|
1051
|
+
|
|
1052
|
+
stream.next({ id: 1, name: "John", active: true });
|
|
1053
|
+
stream.complete();
|
|
1054
|
+
|
|
1055
|
+
sub.unsubscribe();
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### Defer (Lazy Promise)
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
import { Defer } from "@bereasoftware/nexa";
|
|
1062
|
+
|
|
1063
|
+
const deferred = new Defer<string>();
|
|
1064
|
+
|
|
1065
|
+
// Pass the promise to consumers
|
|
1066
|
+
someConsumer(deferred.promise_());
|
|
1067
|
+
|
|
1068
|
+
// Resolve later
|
|
1069
|
+
deferred.resolve("done");
|
|
1070
|
+
// Or reject
|
|
1071
|
+
deferred.reject(new Error("failed"));
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
---
|
|
1075
|
+
|
|
1076
|
+
## Error Handling
|
|
1077
|
+
|
|
1078
|
+
### Error Codes
|
|
1079
|
+
|
|
1080
|
+
| Code | Description |
|
|
1081
|
+
| ------------------ | ------------------------------------------------------- |
|
|
1082
|
+
| `HTTP_ERROR` | Non-2xx HTTP status (configurable via `validateStatus`) |
|
|
1083
|
+
| `TIMEOUT` | Request exceeded timeout duration |
|
|
1084
|
+
| `NETWORK_ERROR` | Network failure (DNS, connection refused, etc.) |
|
|
1085
|
+
| `ABORTED` | Request was manually cancelled |
|
|
1086
|
+
| `VALIDATION_ERROR` | Response data failed validation |
|
|
1087
|
+
| `POLL_EXHAUSTED` | Polling reached max attempts without condition met |
|
|
1088
|
+
| `MAX_RETRIES` | All retry attempts exhausted |
|
|
1089
|
+
| `UNKNOWN_ERROR` | Unclassified error |
|
|
1090
|
+
|
|
1091
|
+
### HttpError Class
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
import { HttpError, isHttpError } from "@bereasoftware/nexa";
|
|
1095
|
+
|
|
1096
|
+
// Check if an error is an HttpError
|
|
1097
|
+
if (isHttpError(error)) {
|
|
1098
|
+
console.log(error.status); // HTTP status code
|
|
1099
|
+
console.log(error.code); // Error code string
|
|
1100
|
+
}
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
### Pattern: Handle Different Error Types
|
|
1104
|
+
|
|
1105
|
+
```typescript
|
|
1106
|
+
const result = await client.get<User>("/users/1");
|
|
1107
|
+
|
|
1108
|
+
if (!result.ok) {
|
|
1109
|
+
switch (result.error.code) {
|
|
1110
|
+
case "TIMEOUT":
|
|
1111
|
+
showNotification("Request timed out, please try again");
|
|
1112
|
+
break;
|
|
1113
|
+
case "NETWORK_ERROR":
|
|
1114
|
+
showNotification("No internet connection");
|
|
1115
|
+
break;
|
|
1116
|
+
case "HTTP_ERROR":
|
|
1117
|
+
if (result.error.status === 404) showNotification("User not found");
|
|
1118
|
+
else if (result.error.status === 403) redirectToLogin();
|
|
1119
|
+
break;
|
|
1120
|
+
case "VALIDATION_ERROR":
|
|
1121
|
+
reportBug("API returned unexpected data format");
|
|
1122
|
+
break;
|
|
1123
|
+
default:
|
|
1124
|
+
reportError(result.error);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
---
|
|
1130
|
+
|
|
1131
|
+
## API Reference
|
|
1132
|
+
|
|
1133
|
+
### `createHttpClient(config?: HttpClientConfig): HttpClient`
|
|
1134
|
+
|
|
1135
|
+
Factory function to create a new HTTP client instance.
|
|
1136
|
+
|
|
1137
|
+
### `HttpClient` Methods
|
|
1138
|
+
|
|
1139
|
+
| Method | Signature | Description |
|
|
1140
|
+
| ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------- |
|
|
1141
|
+
| `request` | `<T>(config: HttpRequestConfig) β Promise<Result<HttpResponse<T>, HttpErrorDetails>>` | Core request method |
|
|
1142
|
+
| `get` | `<T>(url, config?) β Promise<Result<...>>` | GET request |
|
|
1143
|
+
| `post` | `<T>(url, body?, config?) β Promise<Result<...>>` | POST request |
|
|
1144
|
+
| `put` | `<T>(url, body?, config?) β Promise<Result<...>>` | PUT request |
|
|
1145
|
+
| `patch` | `<T>(url, body?, config?) β Promise<Result<...>>` | PATCH request |
|
|
1146
|
+
| `delete` | `<T>(url, config?) β Promise<Result<...>>` | DELETE request |
|
|
1147
|
+
| `head` | `(url, config?) β Promise<Result<...>>` | HEAD request |
|
|
1148
|
+
| `options` | `(url, config?) β Promise<Result<...>>` | OPTIONS request |
|
|
1149
|
+
| `extend` | `(overrides?: HttpClientConfig) β HttpClient` | Create child client |
|
|
1150
|
+
| `paginate` | `<T>(url, options, config?) β AsyncGenerator<T[]>` | Auto-pagination |
|
|
1151
|
+
| `poll` | `<T>(url, options, config?) β Promise<Result<...>>` | Smart polling |
|
|
1152
|
+
| `addRequestInterceptor` | `(interceptor) β Disposer` | Add request interceptor |
|
|
1153
|
+
| `addResponseInterceptor` | `(interceptor) β Disposer` | Add response interceptor |
|
|
1154
|
+
| `clearInterceptors` | `() β void` | Remove all interceptors |
|
|
1155
|
+
| `cancelAll` | `() β void` | Cancel all pending requests |
|
|
1156
|
+
| `activeRequests` | `number` (getter) | Number of in-flight requests |
|
|
1157
|
+
| `queueStats` | `{ active, pending }` (getter) | Queue statistics |
|
|
1158
|
+
|
|
1159
|
+
### Types
|
|
1160
|
+
|
|
1161
|
+
| Type | Description |
|
|
1162
|
+
| --------------------- | --------------------------------------------------------------------------------- |
|
|
1163
|
+
| `Result<T, E>` | Success/failure discriminated union |
|
|
1164
|
+
| `HttpRequest` | Request configuration (url, method, headers, body, query, params) |
|
|
1165
|
+
| `HttpResponse<T>` | Response with data, status, headers, duration |
|
|
1166
|
+
| `HttpErrorDetails` | Error with message, code, status, originalError |
|
|
1167
|
+
| `HttpRequestConfig` | Full request config (extends HttpRequest + retry, cache, hooks, etc.) |
|
|
1168
|
+
| `HttpClientConfig` | Client-level configuration |
|
|
1169
|
+
| `RequestInterceptor` | Intercept outgoing requests |
|
|
1170
|
+
| `ResponseInterceptor` | Intercept incoming responses |
|
|
1171
|
+
| `RetryStrategy` | Custom retry logic interface |
|
|
1172
|
+
| `CacheStrategy` | Custom cache implementation interface |
|
|
1173
|
+
| `Validator` | Response validation interface |
|
|
1174
|
+
| `Transformer` | Response transformation interface |
|
|
1175
|
+
| `PaginateOptions<T>` | Pagination configuration |
|
|
1176
|
+
| `PollOptions<T>` | Polling configuration |
|
|
1177
|
+
| `RequestHooks<T>` | Lifecycle hook callbacks |
|
|
1178
|
+
| `ProgressEvent` | Download progress data |
|
|
1179
|
+
| `ResponseType` | `'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'formData' \| 'stream' \| 'auto'` |
|
|
1180
|
+
| `Disposer` | Function that removes an interceptor |
|
|
1181
|
+
|
|
1182
|
+
---
|
|
1183
|
+
|
|
1184
|
+
## Build Formats
|
|
1185
|
+
|
|
1186
|
+
Nexa ships in multiple module formats:
|
|
1187
|
+
|
|
1188
|
+
| Format | File | Use Case |
|
|
1189
|
+
| --------- | ----------------------- | --------------------------------------- |
|
|
1190
|
+
| **ESM** | `dist/nexa.es.js` | Modern bundlers (Vite, Rollup, esbuild) |
|
|
1191
|
+
| **CJS** | `dist/nexa.cjs.js` | Node.js `require()` |
|
|
1192
|
+
| **UMD** | `dist/nexa.umd.js` | Universal (AMD, CJS, global) |
|
|
1193
|
+
| **IIFE** | `dist/nexa.iife.js` | Script tags (`<script>`) |
|
|
1194
|
+
| **Types** | `dist/types/index.d.ts` | TypeScript type declarations |
|
|
1195
|
+
|
|
1196
|
+
---
|
|
1197
|
+
|
|
1198
|
+
## Development
|
|
1199
|
+
|
|
1200
|
+
### Tests
|
|
1201
|
+
|
|
1202
|
+
**157 tests in total**: 88 HTTP Client tests + 69 utilities tests
|
|
1203
|
+
|
|
1204
|
+
```bash
|
|
1205
|
+
# Run all tests
|
|
1206
|
+
npm test
|
|
1207
|
+
|
|
1208
|
+
# Watch mode
|
|
1209
|
+
npm run test:watch
|
|
1210
|
+
|
|
1211
|
+
# Coverage report
|
|
1212
|
+
npm run test:coverage
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
Tests use **Vitest** (globals mode) with BDD style (`describe`/`it`/`expect`).
|
|
1216
|
+
|
|
1217
|
+
### Build
|
|
1218
|
+
|
|
1219
|
+
```bash
|
|
1220
|
+
# Generate distribution
|
|
1221
|
+
npm run build
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
**Build configuration:**
|
|
1225
|
+
|
|
1226
|
+
- **Formats**: ES, CommonJS, UMD, IIFE
|
|
1227
|
+
- **Minification**: OXC (ultra-fast)
|
|
1228
|
+
- **Type Definitions**: Bundled in `/dist/types`
|
|
1229
|
+
- **Tree-shakeable**: Only imports what you use
|
|
1230
|
+
- **Externals**: `fs` (Node.js only for `streamToFile`)
|
|
1231
|
+
|
|
1232
|
+
**Output:**
|
|
1233
|
+
|
|
1234
|
+
```
|
|
1235
|
+
dist/
|
|
1236
|
+
βββ nexa.es.js (24.9 KB, gzip: 7.53 KB)
|
|
1237
|
+
βββ nexa.cjs.js (19.9 KB, gzip: 6.68 KB)
|
|
1238
|
+
βββ nexa.umd.js (19.8 KB, gzip: 6.75 KB)
|
|
1239
|
+
βββ nexa.iife.js (19.6 KB, gzip: 6.68 KB)
|
|
1240
|
+
βββ types/ (TypeScript .d.ts declarations)
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
### Test Coverage
|
|
1244
|
+
|
|
1245
|
+
**Overall Coverage: 75.73%** β solid unit test coverage with mock-based HTTP testing
|
|
1246
|
+
|
|
1247
|
+
| Component | Coverage | Details |
|
|
1248
|
+
| ----------- | ---------- | --------------------------------- |
|
|
1249
|
+
| HTTP Client | **80.85%** | 81.25% branches, 73.43% functions |
|
|
1250
|
+
| Types | **100%** | Perfect type coverage |
|
|
1251
|
+
| Utils | **71.79%** | 66.66% branches, 81.14% functions |
|
|
1252
|
+
|
|
1253
|
+
**HTTP Client** (`test/http-client.test.ts`) β **88 tests**:
|
|
1254
|
+
|
|
1255
|
+
- β Core methods: create, GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS (7 tests)
|
|
1256
|
+
- β Retry strategies & timeouts (3 tests)
|
|
1257
|
+
- β Interceptors & disposal (5 tests)
|
|
1258
|
+
- β Caching & validation (4 tests)
|
|
1259
|
+
- β Type safety & extensions (3 tests)
|
|
1260
|
+
- β Pagination & polling (5 tests)
|
|
1261
|
+
- β Response type handling: all 8+ types + auto-detection (13 tests)
|
|
1262
|
+
- β Binary content-type detection: image/_, audio/_, video/\*, octet-stream (5 tests)
|
|
1263
|
+
- β Body serialization: JSON, null, strings, Blob, URLSearchParams, ArrayBuffer, TypedArray, FormData, ReadableStream (7 tests)
|
|
1264
|
+
- β Error normalization: TimeoutError, AbortError, TypeError, unknown, NETWORK_ERROR (5+ tests)
|
|
1265
|
+
- β Request management: activeRequests, cancelAll, clearCache (2 tests)
|
|
1266
|
+
- β Module exports verification: all 8 export categories (8 tests)
|
|
1267
|
+
- β Plugin integration: LoggerPlugin, MetricsPlugin event handlers (7 tests)
|
|
1268
|
+
- β Advanced configuration: null body, direct Blob, abort messages (5+ tests)
|
|
1269
|
+
|
|
1270
|
+
**Utilities** (`test/utils.test.ts`) \u2014 **69 tests**:
|
|
1271
|
+
|
|
1272
|
+
- \u2713 Validators: schema, required fields, type checks (4 tests)
|
|
1273
|
+
- \u2713 Transformers: snakeβcamel case, flatten, projection, wrapper (5 tests)
|
|
1274
|
+
- \u2713 Retry Strategies: Aggressive, Conservative, Circuit Breaker (10 tests)
|
|
1275
|
+
- \u2713 Timeout & Retry: withTimeout, retry function (6 tests)
|
|
1276
|
+
- \u2713 Cache: CacheStore CRUD, TTL expiry (5 tests)
|
|
1277
|
+
- \u2713 Deduplication: RequestDeduplicator sharing, cleanup (3 tests)
|
|
1278
|
+
- \u2713 Middleware Pipeline: ordering, next() guard, legacy pipeline (3 tests)
|
|
1279
|
+
- \u2713 Cache Middleware: GET caching, POST bypass (2 tests)
|
|
1280
|
+
- \u2713 Dedup Middleware: GET dedup, POST bypass (2 tests)
|
|
1281
|
+
- \u2713 Typed Generics: TypedResponse, TypedObservable (map/filter), Defer, type guards, branded types (9 tests)
|
|
1282
|
+
- \u2713 Plugins: PluginManager, LoggerPlugin, MetricsPlugin, CachePlugin, DedupePlugin (5 tests)\n\n### Coverage Limitations & Realistic Ceiling\n\nUnit test coverage plateaus around **75-80%** due to inherent mock-based testing limitations:\n\n**Why not 95%?**\n- **Streaming features** (~3-5% gap): Download progress tracking uses `ReadableStream.getReader()` which requires real HTTP streamsβnot mockable with `fetch-mock`\n- **Utility examples** (~5-10% gap): Middleware patterns and reference code are intentionally not exercised in production \n- **Export-only files** (~2-3% gap): `http-client/index.ts` verified via import validation, not unit testable\n\n**Realistic maximums:**\n- Unit tests + mocks: **~80-85%** ceiling (current: 75.73%)\n- Integration tests required: Would reach 90%+ but beyond this project\u2019s scope\n\nThe 75.73% coverage represents comprehensive testing of all **production code paths** that can be reached via HTTP mocks.
|
|
1283
|
+
|
|
1284
|
+
---
|
|
1285
|
+
|
|
1286
|
+
## License
|
|
1287
|
+
|
|
1288
|
+
MIT Β© [John Andrade](mailto:johnandrade@bereasoft.com) β [@bereasoftware](https://github.com/Berea-Soft)
|