@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.md
ADDED
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h1 align="center">@bereasoftware/nexa</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
Un cliente HTTP moderno y type-safe que combina el poder de <code>fetch</code> con la comodidad de <code>axios</code> — construido sobre principios SOLID.
|
|
5
|
+
</p>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="#tests"><img src="https://img.shields.io/badge/Tests-157_pasando-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-20%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/Dependencias-Cero-brightgreen?style=for-the-badge" alt="Dependencies" />
|
|
17
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/Licencia-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-Repositorio-blue?logo=github&style=for-the-badge" alt="Repository" /></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
> 📚 **Documentación disponible en otros idiomas:**
|
|
22
|
+
>
|
|
23
|
+
> - 🇪🇸 **Español** (este archivo - README.md)
|
|
24
|
+
> - 🇬🇧 **English** (README.en.md)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ¿Por qué Nexa?
|
|
29
|
+
|
|
30
|
+
| Característica | `fetch` | `axios` | **Nexa** |
|
|
31
|
+
| ------------------------------------------ | :-----: | :-----: | :------: |
|
|
32
|
+
| Cero dependencias | ✅ | ❌ | ✅ |
|
|
33
|
+
| Errores type-safe (Result monad) | ❌ | ❌ | ✅ |
|
|
34
|
+
| Serialización automática del body | ❌ | ✅ | ✅ |
|
|
35
|
+
| Interpolación de parámetros en ruta | ❌ | ❌ | ✅ |
|
|
36
|
+
| Estrategias de reintentos (pluggable) | ❌ | ❌ | ✅ |
|
|
37
|
+
| Caché integrado | ❌ | ❌ | ✅ |
|
|
38
|
+
| Deduplicación de peticiones | ❌ | ❌ | ✅ |
|
|
39
|
+
| Progreso de descarga | ❌ | ✅ | ✅ |
|
|
40
|
+
| Hooks de ciclo de vida | ❌ | ❌ | ✅ |
|
|
41
|
+
| Limitación de peticiones concurrentes | ❌ | ❌ | ✅ |
|
|
42
|
+
| Auto-paginación | ❌ | ❌ | ✅ |
|
|
43
|
+
| Polling inteligente | ❌ | ❌ | ✅ |
|
|
44
|
+
| Extensión de cliente (`.extend()`) | ❌ | ✅ | ✅ |
|
|
45
|
+
| Disposal de interceptores | ❌ | ❌ | ✅ |
|
|
46
|
+
| Pipeline de middleware | ❌ | ❌ | ✅ |
|
|
47
|
+
| Sistema de plugins | ❌ | ❌ | ✅ |
|
|
48
|
+
| Validadores y transformadores | ❌ | ❌ | ✅ |
|
|
49
|
+
| Tracking de duración de respuesta | ❌ | ❌ | ✅ |
|
|
50
|
+
| Detección inteligente de tipo de respuesta | ❌ | ✅ | ✅ |
|
|
51
|
+
| Tree-shakeable | ✅ | ❌ | ✅ |
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Tabla de Contenidos
|
|
56
|
+
|
|
57
|
+
- [Instalación](#instalación)
|
|
58
|
+
- [Inicio Rápido](#inicio-rápido)
|
|
59
|
+
- [Conceptos Fundamentales](#conceptos-fundamentales)
|
|
60
|
+
- [Result Monad (Sin try/catch)](#result-monad)
|
|
61
|
+
- [Creando un Cliente](#creando-un-cliente)
|
|
62
|
+
- [Métodos HTTP](#métodos-http)
|
|
63
|
+
- [Configuración de Peticiones](#configuración-de-peticiones)
|
|
64
|
+
- [Parámetros de Ruta](#parámetros-de-ruta)
|
|
65
|
+
- [Parámetros de Query](#parámetros-de-query)
|
|
66
|
+
- [Serialización Automática del Body](#serialización-automática-del-body)
|
|
67
|
+
- [Tipos de Respuesta](#tipos-de-respuesta)
|
|
68
|
+
- [Timeout](#timeout)
|
|
69
|
+
- [Estrategias de Reintentos](#estrategias-de-reintentos)
|
|
70
|
+
- [Configuración Inline](#configuración-inline)
|
|
71
|
+
- [AggressiveRetry](#aggressiveretry)
|
|
72
|
+
- [ConservativeRetry](#conservativeretry)
|
|
73
|
+
- [CircuitBreakerRetry](#circuitbreakerretry)
|
|
74
|
+
- [Estrategia Personalizada](#estrategia-personalizada)
|
|
75
|
+
- [Interceptores](#interceptores)
|
|
76
|
+
- [Interceptores de Petición](#interceptores-de-petición)
|
|
77
|
+
- [Interceptores de Respuesta](#interceptores-de-respuesta)
|
|
78
|
+
- [Disposal de Interceptores](#disposal-de-interceptores)
|
|
79
|
+
- [Caché](#caché)
|
|
80
|
+
- [Hooks de Ciclo de Vida](#hooks-de-ciclo-de-vida)
|
|
81
|
+
- [Progreso de Descarga](#progreso-de-descarga)
|
|
82
|
+
- [Limitación de Peticiones Concurrentes](#limitación-de-peticiones-concurrentes)
|
|
83
|
+
- [Extensión de Cliente](#extensión-de-cliente)
|
|
84
|
+
- [Auto-Paginación](#auto-paginación)
|
|
85
|
+
- [Polling Inteligente](#polling-inteligente)
|
|
86
|
+
- [Cancelación de Peticiones](#cancelación-de-peticiones)
|
|
87
|
+
- [Validadores](#validadores)
|
|
88
|
+
- [Transformadores](#transformadores)
|
|
89
|
+
- [Pipeline de Middleware](#pipeline-de-middleware)
|
|
90
|
+
- [Sistema de Plugins](#sistema-de-plugins)
|
|
91
|
+
- [Streaming](#streaming)
|
|
92
|
+
- [Generics Tipados](#generics-tipados)
|
|
93
|
+
- [Manejo de Errores](#manejo-de-errores)
|
|
94
|
+
- [Referencia de API](#referencia-de-api)
|
|
95
|
+
- [Formatos de Build](#formatos-de-build)
|
|
96
|
+
- [Desarrollo](#desarrollo)
|
|
97
|
+
- [Licencia](#licencia)
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Instalación
|
|
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
|
+
## Inicio Rápido
|
|
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, sin necesidad de try/catch
|
|
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
|
+
## Conceptos Fundamentales
|
|
142
|
+
|
|
143
|
+
### Result Monad
|
|
144
|
+
|
|
145
|
+
Nexa retorna un tipo `Result<T, E>` en lugar de lanzar excepciones. Esto elimina la necesidad de bloques `try/catch` y te da seguridad de tipos completa tanto en el camino de éxito como en el de error.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
También puedes construir resultados manualmente:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { Ok, Err } from "@bereasoftware/nexa";
|
|
155
|
+
|
|
156
|
+
const exito = Ok({ name: "John" }); // { ok: true, value: { name: 'John' } }
|
|
157
|
+
const fallo = Err({ message: "No encontrado", code: "HTTP_ERROR" });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Cada método del cliente retorna `Promise<Result<HttpResponse<T>, HttpErrorDetails>>`:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
const result = await client.get<User[]>('/users');
|
|
164
|
+
|
|
165
|
+
if (result.ok) {
|
|
166
|
+
// result.value es 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 es 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
|
+
### Creando un Cliente
|
|
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 (por defecto: 30s)
|
|
188
|
+
validateStatus: (status) => status < 400, // Validación de status personalizada
|
|
189
|
+
maxConcurrent: 5, // Máximo 5 peticiones simultáneas
|
|
190
|
+
defaultResponseType: "json", // 'json' | 'text' | 'blob' | 'auto' | ...
|
|
191
|
+
defaultHooks: {
|
|
192
|
+
onStart: (req) => console.log("Iniciando:", req.url),
|
|
193
|
+
onFinally: () => console.log("Listo"),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Opciones completas de `HttpClientConfig`:**
|
|
199
|
+
|
|
200
|
+
| Opción | Tipo | Por defecto | Descripción |
|
|
201
|
+
| --------------------- | ----------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
|
|
202
|
+
| `baseURL` | `string` | `''` | URL base que se antepone a todas las peticiones |
|
|
203
|
+
| `defaultHeaders` | `Record<string, string>` | `{ 'Content-Type': 'application/json' }` | Headers por defecto para cada petición |
|
|
204
|
+
| `defaultTimeout` | `number` | `30000` | Timeout por defecto en ms |
|
|
205
|
+
| `validateStatus` | `(status: number) => boolean` | `status >= 200 && status < 300` | Qué códigos HTTP se consideran exitosos |
|
|
206
|
+
| `cacheStrategy` | `CacheStrategy` | `MemoryCache` | Implementación de caché personalizada |
|
|
207
|
+
| `maxConcurrent` | `number` | `0` (ilimitado) | Máximo de peticiones concurrentes |
|
|
208
|
+
| `defaultResponseType` | `ResponseType` | `'auto'` | Estrategia de parseo de respuesta por defecto |
|
|
209
|
+
| `defaultHooks` | `RequestHooks` | `{}` | Hooks de ciclo de vida por defecto para todas las peticiones |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Métodos HTTP
|
|
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 Actualizado" });
|
|
227
|
+
|
|
228
|
+
// PATCH
|
|
229
|
+
const result = await client.patch<User>("/users/1", {
|
|
230
|
+
email: "nuevo@example.com",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// DELETE
|
|
234
|
+
const result = await client.delete<void>("/users/1");
|
|
235
|
+
|
|
236
|
+
// HEAD (verificar existencia de recurso)
|
|
237
|
+
const result = await client.head("/users/1");
|
|
238
|
+
|
|
239
|
+
// OPTIONS (preflight CORS, métodos disponibles)
|
|
240
|
+
const result = await client.options("/users");
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Todos los métodos aceptan un objeto de configuración opcional como último parámetro:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
const result = await client.get<User>("/users/1", {
|
|
247
|
+
timeout: 5000,
|
|
248
|
+
headers: { "X-Custom": "valor" },
|
|
249
|
+
cache: { enabled: true, ttlMs: 60000 },
|
|
250
|
+
retry: { maxAttempts: 3, backoffMs: 1000 },
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Configuración de Peticiones
|
|
257
|
+
|
|
258
|
+
### Parámetros de Ruta
|
|
259
|
+
|
|
260
|
+
Nexa soporta interpolación de rutas estilo `:param` con codificación URI automática:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
const result = await client.get<User>("/users/:id/posts/:postId", {
|
|
264
|
+
params: { id: 42, postId: "hola mundo" },
|
|
265
|
+
});
|
|
266
|
+
// → GET /users/42/posts/hola%20mundo
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Parámetros de Query
|
|
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
|
+
### Serialización Automática del Body
|
|
279
|
+
|
|
280
|
+
Nexa detecta y serializa automáticamente el cuerpo de la petición:
|
|
281
|
+
|
|
282
|
+
| Tipo de Body | Serialización | Content-Type |
|
|
283
|
+
| ------------------ | ------------------ | ----------------------------------- |
|
|
284
|
+
| `object` / `array` | `JSON.stringify()` | `application/json` |
|
|
285
|
+
| `string` | Se envía tal cual | `text/plain` |
|
|
286
|
+
| `FormData` | Se envía tal cual | Auto (boundary multipart) |
|
|
287
|
+
| `URLSearchParams` | Se envía tal cual | `application/x-www-form-urlencoded` |
|
|
288
|
+
| `Blob` | Se envía tal cual | Tipo del Blob |
|
|
289
|
+
| `ArrayBuffer` | Se envía tal cual | `application/octet-stream` |
|
|
290
|
+
| `ReadableStream` | Se envía tal cual | `application/octet-stream` |
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// JSON (automático)
|
|
294
|
+
await client.post("/users", { name: "John" });
|
|
295
|
+
|
|
296
|
+
// FormData (content-type automático con 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: "secreto" }),
|
|
305
|
+
);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Tipos de Respuesta
|
|
309
|
+
|
|
310
|
+
Controla cómo se parsea el cuerpo de la respuesta:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// Auto-detección basada en el header Content-Type (por defecto)
|
|
314
|
+
const result = await client.get("/data", { responseType: "auto" });
|
|
315
|
+
|
|
316
|
+
// Forzar parseo JSON
|
|
317
|
+
const result = await client.get<User>("/user", { responseType: "json" });
|
|
318
|
+
|
|
319
|
+
// Obtener texto crudo
|
|
320
|
+
const result = await client.get<string>("/page", { responseType: "text" });
|
|
321
|
+
|
|
322
|
+
// Descargar como Blob
|
|
323
|
+
const result = await client.get<Blob>("/file.pdf", { responseType: "blob" });
|
|
324
|
+
|
|
325
|
+
// Obtener ArrayBuffer
|
|
326
|
+
const result = await client.get<ArrayBuffer>("/binary", {
|
|
327
|
+
responseType: "arrayBuffer",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Obtener FormData
|
|
331
|
+
const result = await client.get<FormData>("/form", {
|
|
332
|
+
responseType: "formData",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Obtener ReadableStream (para streaming manual)
|
|
336
|
+
const result = await client.get<ReadableStream>("/stream", {
|
|
337
|
+
responseType: "stream",
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Lógica de auto-detección:** `application/json` → JSON, `text/*` → texto, `multipart/form-data` → FormData, `application/octet-stream` / `image/*` / `audio/*` / `video/*` → Blob, fallback → intenta JSON luego texto.
|
|
342
|
+
|
|
343
|
+
### Timeout
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
// Timeout por petición
|
|
347
|
+
const result = await client.get("/endpoint-lento", { timeout: 5000 });
|
|
348
|
+
|
|
349
|
+
// El timeout produce un código de error específico
|
|
350
|
+
if (!result.ok && result.error.code === "TIMEOUT") {
|
|
351
|
+
console.log("La petición expiró");
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Estrategias de Reintentos
|
|
358
|
+
|
|
359
|
+
### Configuración Inline
|
|
360
|
+
|
|
361
|
+
Reintento simple con backoff exponencial:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
const result = await client.get("/api-inestable", {
|
|
365
|
+
retry: { maxAttempts: 3, backoffMs: 1000 },
|
|
366
|
+
});
|
|
367
|
+
// Reintenta hasta 3 veces con backoff exponencial + jitter
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### AggressiveRetry
|
|
371
|
+
|
|
372
|
+
Reintenta todos los errores hasta el máximo de intentos con delay mínimo:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { AggressiveRetry } from "@bereasoftware/nexa";
|
|
376
|
+
|
|
377
|
+
const result = await client.get("/api", {
|
|
378
|
+
retry: new AggressiveRetry(5), // 5 intentos, delay de 50ms * intento
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### ConservativeRetry
|
|
383
|
+
|
|
384
|
+
Solo reintenta en códigos HTTP específicos (408, 429, 500, 502, 503, 504) y timeouts:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import { ConservativeRetry } from "@bereasoftware/nexa";
|
|
388
|
+
|
|
389
|
+
const result = await client.get("/api", {
|
|
390
|
+
retry: new ConservativeRetry(3), // 3 intentos, backoff exponencial con tope de 10s
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### CircuitBreakerRetry
|
|
395
|
+
|
|
396
|
+
Patrón fail-fast — deja de reintentar después de un umbral de fallos:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import { CircuitBreakerRetry } from "@bereasoftware/nexa";
|
|
400
|
+
|
|
401
|
+
const breaker = new CircuitBreakerRetry(
|
|
402
|
+
3, // maxAttempts por petición
|
|
403
|
+
5, // failureThreshold antes de abrir el circuito
|
|
404
|
+
60000, // resetTimeMs — el circuito se resetea después de 60s
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const result = await client.get("/api", { retry: breaker });
|
|
408
|
+
|
|
409
|
+
// Resetear el circuito manualmente
|
|
410
|
+
breaker.reset();
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Estrategia Personalizada
|
|
414
|
+
|
|
415
|
+
Implementa la interfaz `RetryStrategy`:
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
import type { RetryStrategy, HttpErrorDetails } from "@bereasoftware/nexa";
|
|
419
|
+
|
|
420
|
+
const reintentoCustom: RetryStrategy = {
|
|
421
|
+
shouldRetry(attempt: number, error: HttpErrorDetails): boolean {
|
|
422
|
+
// Solo reintentar errores de red y 503
|
|
423
|
+
return (
|
|
424
|
+
(error.code === "NETWORK_ERROR" || error.status === 503) && attempt < 5
|
|
425
|
+
);
|
|
426
|
+
},
|
|
427
|
+
delayMs(attempt: number): number {
|
|
428
|
+
// Backoff lineal: 500ms, 1000ms, 1500ms...
|
|
429
|
+
return attempt * 500;
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const result = await client.get("/api", { retry: reintentoCustom });
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Interceptores
|
|
439
|
+
|
|
440
|
+
### Interceptores de Petición
|
|
441
|
+
|
|
442
|
+
Modifica las peticiones antes de que se envíen:
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
client.addRequestInterceptor({
|
|
446
|
+
onRequest(request) {
|
|
447
|
+
// Agregar token de auth a cada petición
|
|
448
|
+
return {
|
|
449
|
+
...request,
|
|
450
|
+
headers: {
|
|
451
|
+
...request.headers,
|
|
452
|
+
Authorization: `Bearer ${getToken()}`,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Interceptores de Respuesta
|
|
460
|
+
|
|
461
|
+
Transforma respuestas o maneja errores globalmente:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
client.addResponseInterceptor({
|
|
465
|
+
onResponse(response) {
|
|
466
|
+
// Loguear todas las respuestas exitosas
|
|
467
|
+
console.log(
|
|
468
|
+
`[${response.status}] ${response.request.url} (${response.duration}ms)`,
|
|
469
|
+
);
|
|
470
|
+
return response;
|
|
471
|
+
},
|
|
472
|
+
onError(error) {
|
|
473
|
+
// Manejar 401 globalmente
|
|
474
|
+
if (error.status === 401) {
|
|
475
|
+
redirigirAlLogin();
|
|
476
|
+
}
|
|
477
|
+
return error;
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Disposal de Interceptores
|
|
483
|
+
|
|
484
|
+
Tanto `addRequestInterceptor` como `addResponseInterceptor` retornan una función disposer para remover el interceptor:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const dispose = client.addRequestInterceptor({
|
|
488
|
+
onRequest(request) {
|
|
489
|
+
return { ...request, headers: { ...request.headers, "X-Temp": "valor" } };
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Después: remover el interceptor
|
|
494
|
+
dispose();
|
|
495
|
+
|
|
496
|
+
// O limpiar todos los interceptores
|
|
497
|
+
client.clearInterceptors();
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Caché
|
|
503
|
+
|
|
504
|
+
Caché en memoria integrado con soporte TTL. Solo cachea peticiones GET:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
const result = await client.get<User>("/users/1", {
|
|
508
|
+
cache: { enabled: true, ttlMs: 60000 }, // Cachear por 1 minuto
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// La segunda llamada retorna la respuesta cacheada instantáneamente
|
|
512
|
+
const cached = await client.get<User>("/users/1", {
|
|
513
|
+
cache: { enabled: true, ttlMs: 60000 },
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
**Implementación de caché personalizada:**
|
|
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
|
+
## Hooks de Ciclo de Vida
|
|
543
|
+
|
|
544
|
+
Monitorea el ciclo de vida completo de la petición:
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
const result = await client.get<User>("/users/1", {
|
|
548
|
+
hooks: {
|
|
549
|
+
onStart(request) {
|
|
550
|
+
console.log("Iniciando petición a:", request.url);
|
|
551
|
+
},
|
|
552
|
+
onSuccess(response) {
|
|
553
|
+
console.log("Éxito:", response.status, `(${response.duration}ms)`);
|
|
554
|
+
},
|
|
555
|
+
onError(error) {
|
|
556
|
+
console.error("Falló:", error.message, error.code);
|
|
557
|
+
},
|
|
558
|
+
onRetry(attempt, error) {
|
|
559
|
+
console.warn(`Reintento #${attempt}:`, error.message);
|
|
560
|
+
},
|
|
561
|
+
onFinally() {
|
|
562
|
+
console.log("Petición completada (éxito o fallo)");
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Los hooks por defecto se pueden configurar a nivel de cliente:
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
const client = createHttpClient({
|
|
572
|
+
defaultHooks: {
|
|
573
|
+
onError: (error) => reportarASentry(error),
|
|
574
|
+
onFinally: () => ocultarSpinnerDeCarga(),
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Progreso de Descarga
|
|
582
|
+
|
|
583
|
+
Trackea el progreso de descarga con un callback:
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
const result = await client.get<Blob>("/archivo-grande.zip", {
|
|
587
|
+
responseType: "blob",
|
|
588
|
+
onDownloadProgress(event) {
|
|
589
|
+
console.log(
|
|
590
|
+
`Descargado: ${event.percent}% (${event.loaded}/${event.total} bytes)`,
|
|
591
|
+
);
|
|
592
|
+
actualizarBarraDeProgreso(event.percent);
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
La interfaz `ProgressEvent`:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
interface ProgressEvent {
|
|
601
|
+
loaded: number; // Bytes descargados hasta ahora
|
|
602
|
+
total: number; // Total de bytes (del header Content-Length)
|
|
603
|
+
percent: number; // 0-100
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Limitación de Peticiones Concurrentes
|
|
610
|
+
|
|
611
|
+
Limita el número de peticiones simultáneas para no sobrecargar el servidor:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
const client = createHttpClient({
|
|
615
|
+
baseURL: "https://api.example.com",
|
|
616
|
+
maxConcurrent: 3, // Solo 3 peticiones a la vez
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Lanza 10 peticiones — solo 3 corren simultáneamente, el resto se encola automáticamente
|
|
620
|
+
const results = await Promise.all(urls.map((url) => client.get(url)));
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Consultar el estado de la cola:
|
|
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
|
+
## Extensión de Cliente
|
|
636
|
+
|
|
637
|
+
Crea clientes hijo que heredan configuración e interceptores:
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
const clienteBase = createHttpClient({
|
|
641
|
+
baseURL: "https://api.example.com",
|
|
642
|
+
defaultHeaders: { "X-App": "MiApp" },
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
clienteBase.addRequestInterceptor({
|
|
646
|
+
onRequest(req) {
|
|
647
|
+
return {
|
|
648
|
+
...req,
|
|
649
|
+
headers: { ...req.headers, Authorization: "Bearer token" },
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// El hijo hereda baseURL, headers, interceptores — y agrega header de versión
|
|
655
|
+
const clienteV2 = clienteBase.extend({
|
|
656
|
+
defaultHeaders: { "X-API-Version": "2" },
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// clienteV2 tiene headers: { 'X-App': 'MiApp', 'X-API-Version': '2' }
|
|
660
|
+
// clienteV2 también tiene el interceptor de auth del clienteBase
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
## Auto-Paginación
|
|
666
|
+
|
|
667
|
+
Itera a través de APIs paginadas con generadores asíncronos:
|
|
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("Página con", users.length, "usuarios");
|
|
686
|
+
// Procesar cada página de usuarios
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
La paginación se detiene automáticamente cuando `getNextPage` retorna `null` o una petición falla.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## Polling Inteligente
|
|
695
|
+
|
|
696
|
+
Consulta un endpoint repetidamente hasta que se cumpla una condición:
|
|
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, // Consultar cada 2 segundos
|
|
707
|
+
maxAttempts: 30, // Rendirse después de 30 intentos (0 = ilimitado)
|
|
708
|
+
until: (job) => job.status === "completed" || job.status === "failed",
|
|
709
|
+
onPoll: (job, attempt) => {
|
|
710
|
+
console.log(`Intento ${attempt}: ${job.status}`);
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
if (result.ok) {
|
|
715
|
+
console.log("Job terminado:", result.value.data.result);
|
|
716
|
+
} else if (result.error.code === "POLL_EXHAUSTED") {
|
|
717
|
+
console.log("El polling expiró");
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## Cancelación de Peticiones
|
|
724
|
+
|
|
725
|
+
Cancela todas las peticiones pendientes:
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
// Iniciar varias peticiones
|
|
729
|
+
const promise1 = client.get("/lento-1");
|
|
730
|
+
const promise2 = client.get("/lento-2");
|
|
731
|
+
|
|
732
|
+
// Cancelar todo
|
|
733
|
+
client.cancelAll();
|
|
734
|
+
|
|
735
|
+
// O usar AbortSignal para peticiones individuales
|
|
736
|
+
const controller = new AbortController();
|
|
737
|
+
const result = client.get("/data", { signal: controller.signal });
|
|
738
|
+
controller.abort();
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
## Validadores
|
|
744
|
+
|
|
745
|
+
Valida los datos de respuesta antes de que lleguen a tu código:
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
import {
|
|
749
|
+
createSchemaValidator,
|
|
750
|
+
createRequiredFieldsValidator,
|
|
751
|
+
validatorIsArray,
|
|
752
|
+
validatorIsObject,
|
|
753
|
+
} from "@bereasoftware/nexa";
|
|
754
|
+
|
|
755
|
+
// Validador de esquema
|
|
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
|
+
// Si la validación falla: result.error.code === 'VALIDATION_ERROR'
|
|
766
|
+
|
|
767
|
+
// Validador de campos requeridos
|
|
768
|
+
const result = await client.get("/api/data", {
|
|
769
|
+
validate: createRequiredFieldsValidator(["id", "name", "createdAt"]),
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Validador de array
|
|
773
|
+
const result = await client.get("/users", {
|
|
774
|
+
validate: validatorIsArray,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Validador de objeto
|
|
778
|
+
const result = await client.get("/user/1", {
|
|
779
|
+
validate: validatorIsObject,
|
|
780
|
+
});
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
## Transformadores
|
|
786
|
+
|
|
787
|
+
Transforma los datos de respuesta después del parseo:
|
|
788
|
+
|
|
789
|
+
```typescript
|
|
790
|
+
import {
|
|
791
|
+
transformSnakeToCamel,
|
|
792
|
+
transformCamelToSnake,
|
|
793
|
+
transformFlatten,
|
|
794
|
+
createProjectionTransformer,
|
|
795
|
+
createWrapperTransformer,
|
|
796
|
+
} from "@bereasoftware/nexa";
|
|
797
|
+
|
|
798
|
+
// Convertir respuestas snake_case de la API a camelCase
|
|
799
|
+
const result = await client.get("/users/1", {
|
|
800
|
+
transform: transformSnakeToCamel,
|
|
801
|
+
});
|
|
802
|
+
// { first_name: 'John' } → { firstName: 'John' }
|
|
803
|
+
|
|
804
|
+
// Convertir camelCase a snake_case (para enviar datos)
|
|
805
|
+
const result = await client.get("/data", {
|
|
806
|
+
transform: transformCamelToSnake,
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Aplanar objetos anidados
|
|
810
|
+
const result = await client.get("/nested", {
|
|
811
|
+
transform: transformFlatten,
|
|
812
|
+
});
|
|
813
|
+
// { user: { name: 'John' } } → { 'user.name': 'John' }
|
|
814
|
+
|
|
815
|
+
// Seleccionar campos específicos
|
|
816
|
+
const result = await client.get("/users/1", {
|
|
817
|
+
transform: createProjectionTransformer(["id", "name"]),
|
|
818
|
+
});
|
|
819
|
+
// Solo mantiene { id, name } de la respuesta
|
|
820
|
+
|
|
821
|
+
// Envolver datos en un contenedor
|
|
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
|
+
## Pipeline de Middleware
|
|
831
|
+
|
|
832
|
+
Pipeline de middleware estilo Express/Koa para procesamiento avanzado de peticiones:
|
|
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
|
+
// Crear middleware personalizado
|
|
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
|
+
// Construir y ejecutar 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
|
+
**Middleware pre-construidos:**
|
|
875
|
+
|
|
876
|
+
| Middleware | Descripción |
|
|
877
|
+
| ------------------------------------- | ---------------------------------------------- |
|
|
878
|
+
| `createCacheMiddleware(options?)` | Cachea respuestas GET con TTL |
|
|
879
|
+
| `cacheMiddleware` | Caché pre-configurado (60s TTL) |
|
|
880
|
+
| `createDedupeMiddleware(options?)` | Deduplica peticiones concurrentes idénticas |
|
|
881
|
+
| `dedupeMiddleware` | Deduplicación pre-configurada para GET |
|
|
882
|
+
| `createStreamingMiddleware(options?)` | Maneja respuestas streaming con progreso |
|
|
883
|
+
| `streamingMiddleware` | Streaming pre-configurado con salida a consola |
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
887
|
+
## Sistema de Plugins
|
|
888
|
+
|
|
889
|
+
Extiende Nexa con una arquitectura de plugins:
|
|
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
|
+
// Registrar plugins
|
|
903
|
+
manager
|
|
904
|
+
.register(LoggerPlugin)
|
|
905
|
+
.register(new MetricsPlugin())
|
|
906
|
+
.register(new CachePlugin(30000)) // 30s TTL
|
|
907
|
+
.register(new DedupePlugin());
|
|
908
|
+
|
|
909
|
+
// Escuchar eventos
|
|
910
|
+
manager.on("request:start", (url) => console.log("Petición a:", url));
|
|
911
|
+
manager.on("request:success", (url, status) =>
|
|
912
|
+
console.log("Éxito:", url, status),
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Obtener métricas
|
|
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
|
+
**Crear plugins personalizados:**
|
|
923
|
+
|
|
924
|
+
```typescript
|
|
925
|
+
import type { Plugin } from "@bereasoftware/nexa";
|
|
926
|
+
|
|
927
|
+
const rateLimitPlugin: Plugin = {
|
|
928
|
+
name: "rate-limit",
|
|
929
|
+
setup(client) {
|
|
930
|
+
// Agregar middleware de rate limiting, event listeners, etc.
|
|
931
|
+
const manager = client as PluginManager;
|
|
932
|
+
manager.on("request:start", () => {
|
|
933
|
+
// Lógica de rate limiting personalizada
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
manager.register(rateLimitPlugin);
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
943
|
+
## Streaming
|
|
944
|
+
|
|
945
|
+
Maneja archivos grandes y respuestas streaming:
|
|
946
|
+
|
|
947
|
+
```typescript
|
|
948
|
+
import { handleStream, streamToFile } from "@bereasoftware/nexa";
|
|
949
|
+
|
|
950
|
+
// Procesamiento de stream manual
|
|
951
|
+
const response = await fetch("https://example.com/archivo-grande");
|
|
952
|
+
const data = await handleStream(response, {
|
|
953
|
+
onChunk(chunk) {
|
|
954
|
+
console.log("Chunk recibido:", chunk.length, "bytes");
|
|
955
|
+
},
|
|
956
|
+
onProgress(loaded, total) {
|
|
957
|
+
console.log(`Progreso: ${Math.round((loaded / total) * 100)}%`);
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Descargar stream a archivo
|
|
962
|
+
const response = await fetch("https://example.com/datos.csv");
|
|
963
|
+
await streamToFile(response, "salida.csv");
|
|
964
|
+
// Funciona tanto en Node.js (fs.writeFile) como en navegador (descarga Blob)
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## Generics Tipados
|
|
970
|
+
|
|
971
|
+
Utilidades avanzadas type-safe para diseño de clientes API:
|
|
972
|
+
|
|
973
|
+
### Cliente API Tipado
|
|
974
|
+
|
|
975
|
+
```typescript
|
|
976
|
+
import { createTypedApiClient, type ApiEndpoint } from "@bereasoftware/nexa";
|
|
977
|
+
|
|
978
|
+
// Define tu esquema API con tipos completos
|
|
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
|
+
// Petición totalmente tipada — conoce los tipos de entrada y salida
|
|
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
|
+
### Tipos Branded
|
|
1000
|
+
|
|
1001
|
+
```typescript
|
|
1002
|
+
import {
|
|
1003
|
+
createUrl,
|
|
1004
|
+
createApiUrl,
|
|
1005
|
+
type Url,
|
|
1006
|
+
type ApiUrl,
|
|
1007
|
+
type FileUrl,
|
|
1008
|
+
} from "@bereasoftware/nexa";
|
|
1009
|
+
|
|
1010
|
+
// Las URLs branded previenen mezclar diferentes tipos de URL en tiempo de compilación
|
|
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 asegurarUser = 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 = asegurarUser(datosDesconocidos); // lanza TypeError si es inválido
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
### Patrón Observable
|
|
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("Usuario:", user.name),
|
|
1045
|
+
(err) => console.error("Error:", err),
|
|
1046
|
+
() => console.log("Completado"),
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Operadores encadenables
|
|
1050
|
+
const nombres = 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 (Promesa Lazy)
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
import { Defer } from "@bereasoftware/nexa";
|
|
1062
|
+
|
|
1063
|
+
const deferred = new Defer<string>();
|
|
1064
|
+
|
|
1065
|
+
// Pasar la promesa a los consumidores
|
|
1066
|
+
algunConsumidor(deferred.promise_());
|
|
1067
|
+
|
|
1068
|
+
// Resolver después
|
|
1069
|
+
deferred.resolve("listo");
|
|
1070
|
+
// O rechazar
|
|
1071
|
+
deferred.reject(new Error("falló"));
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
---
|
|
1075
|
+
|
|
1076
|
+
## Manejo de Errores
|
|
1077
|
+
|
|
1078
|
+
### Códigos de Error
|
|
1079
|
+
|
|
1080
|
+
| Código | Descripción |
|
|
1081
|
+
| ------------------ | ----------------------------------------------------------------- |
|
|
1082
|
+
| `HTTP_ERROR` | Status HTTP no-2xx (configurable vía `validateStatus`) |
|
|
1083
|
+
| `TIMEOUT` | La petición excedió la duración del timeout |
|
|
1084
|
+
| `NETWORK_ERROR` | Fallo de red (DNS, conexión rechazada, etc.) |
|
|
1085
|
+
| `ABORTED` | La petición fue cancelada manualmente |
|
|
1086
|
+
| `VALIDATION_ERROR` | Los datos de respuesta no pasaron la validación |
|
|
1087
|
+
| `POLL_EXHAUSTED` | El polling alcanzó el máximo de intentos sin cumplir la condición |
|
|
1088
|
+
| `MAX_RETRIES` | Todos los intentos de reintento agotados |
|
|
1089
|
+
| `UNKNOWN_ERROR` | Error no clasificado |
|
|
1090
|
+
|
|
1091
|
+
### Clase HttpError
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
import { HttpError, isHttpError } from "@bereasoftware/nexa";
|
|
1095
|
+
|
|
1096
|
+
// Verificar si un error es un HttpError
|
|
1097
|
+
if (isHttpError(error)) {
|
|
1098
|
+
console.log(error.status); // Código de status HTTP
|
|
1099
|
+
console.log(error.code); // Cadena de código de error
|
|
1100
|
+
}
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
### Patrón: Manejar Diferentes Tipos de Error
|
|
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
|
+
mostrarNotificacion("La petición expiró, intenta de nuevo");
|
|
1112
|
+
break;
|
|
1113
|
+
case "NETWORK_ERROR":
|
|
1114
|
+
mostrarNotificacion("Sin conexión a internet");
|
|
1115
|
+
break;
|
|
1116
|
+
case "HTTP_ERROR":
|
|
1117
|
+
if (result.error.status === 404)
|
|
1118
|
+
mostrarNotificacion("Usuario no encontrado");
|
|
1119
|
+
else if (result.error.status === 403) redirigirAlLogin();
|
|
1120
|
+
break;
|
|
1121
|
+
case "VALIDATION_ERROR":
|
|
1122
|
+
reportarBug("La API retornó un formato de datos inesperado");
|
|
1123
|
+
break;
|
|
1124
|
+
default:
|
|
1125
|
+
reportarError(result.error);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
---
|
|
1131
|
+
|
|
1132
|
+
## Referencia de API
|
|
1133
|
+
|
|
1134
|
+
### `createHttpClient(config?: HttpClientConfig): HttpClient`
|
|
1135
|
+
|
|
1136
|
+
Función factory para crear una nueva instancia del cliente HTTP.
|
|
1137
|
+
|
|
1138
|
+
### Métodos de `HttpClient`
|
|
1139
|
+
|
|
1140
|
+
| Método | Firma | Descripción |
|
|
1141
|
+
| ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
1142
|
+
| `request` | `<T>(config: HttpRequestConfig) → Promise<Result<HttpResponse<T>, HttpErrorDetails>>` | Método core de petición |
|
|
1143
|
+
| `get` | `<T>(url, config?) → Promise<Result<...>>` | Petición GET |
|
|
1144
|
+
| `post` | `<T>(url, body?, config?) → Promise<Result<...>>` | Petición POST |
|
|
1145
|
+
| `put` | `<T>(url, body?, config?) → Promise<Result<...>>` | Petición PUT |
|
|
1146
|
+
| `patch` | `<T>(url, body?, config?) → Promise<Result<...>>` | Petición PATCH |
|
|
1147
|
+
| `delete` | `<T>(url, config?) → Promise<Result<...>>` | Petición DELETE |
|
|
1148
|
+
| `head` | `(url, config?) → Promise<Result<...>>` | Petición HEAD |
|
|
1149
|
+
| `options` | `(url, config?) → Promise<Result<...>>` | Petición OPTIONS |
|
|
1150
|
+
| `extend` | `(overrides?: HttpClientConfig) → HttpClient` | Crear cliente hijo |
|
|
1151
|
+
| `paginate` | `<T>(url, options, config?) → AsyncGenerator<T[]>` | Auto-paginación |
|
|
1152
|
+
| `poll` | `<T>(url, options, config?) → Promise<Result<...>>` | Polling inteligente |
|
|
1153
|
+
| `addRequestInterceptor` | `(interceptor) → Disposer` | Agregar interceptor de petición |
|
|
1154
|
+
| `addResponseInterceptor` | `(interceptor) → Disposer` | Agregar interceptor de respuesta |
|
|
1155
|
+
| `clearInterceptors` | `() → void` | Remover todos los interceptores |
|
|
1156
|
+
| `cancelAll` | `() → void` | Cancelar todas las peticiones pendientes |
|
|
1157
|
+
| `activeRequests` | `number` (getter) | Número de peticiones en vuelo |
|
|
1158
|
+
| `queueStats` | `{ active, pending }` (getter) | Estadísticas de la cola |
|
|
1159
|
+
|
|
1160
|
+
### Tipos
|
|
1161
|
+
|
|
1162
|
+
| Tipo | Descripción |
|
|
1163
|
+
| --------------------- | --------------------------------------------------------------------------------- |
|
|
1164
|
+
| `Result<T, E>` | Unión discriminada éxito/fallo |
|
|
1165
|
+
| `HttpRequest` | Configuración de petición (url, method, headers, body, query, params) |
|
|
1166
|
+
| `HttpResponse<T>` | Respuesta con data, status, headers, duration |
|
|
1167
|
+
| `HttpErrorDetails` | Error con message, code, status, originalError |
|
|
1168
|
+
| `HttpRequestConfig` | Config completa de petición (extiende HttpRequest + retry, cache, hooks, etc.) |
|
|
1169
|
+
| `HttpClientConfig` | Configuración a nivel de cliente |
|
|
1170
|
+
| `RequestInterceptor` | Interceptar peticiones salientes |
|
|
1171
|
+
| `ResponseInterceptor` | Interceptar respuestas entrantes |
|
|
1172
|
+
| `RetryStrategy` | Interfaz de lógica de reintento personalizada |
|
|
1173
|
+
| `CacheStrategy` | Interfaz de implementación de caché personalizada |
|
|
1174
|
+
| `Validator` | Interfaz de validación de respuesta |
|
|
1175
|
+
| `Transformer` | Interfaz de transformación de respuesta |
|
|
1176
|
+
| `PaginateOptions<T>` | Configuración de paginación |
|
|
1177
|
+
| `PollOptions<T>` | Configuración de polling |
|
|
1178
|
+
| `RequestHooks<T>` | Callbacks de hooks de ciclo de vida |
|
|
1179
|
+
| `ProgressEvent` | Datos de progreso de descarga |
|
|
1180
|
+
| `ResponseType` | `'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'formData' \| 'stream' \| 'auto'` |
|
|
1181
|
+
| `Disposer` | Función que remueve un interceptor |
|
|
1182
|
+
|
|
1183
|
+
---
|
|
1184
|
+
|
|
1185
|
+
## Formatos de Build
|
|
1186
|
+
|
|
1187
|
+
Nexa se distribuye en múltiples formatos de módulo:
|
|
1188
|
+
|
|
1189
|
+
| Formato | Archivo | Caso de Uso |
|
|
1190
|
+
| --------- | ----------------------- | ----------------------------------------- |
|
|
1191
|
+
| **ESM** | `dist/nexa.es.js` | Bundlers modernos (Vite, Rollup, esbuild) |
|
|
1192
|
+
| **CJS** | `dist/nexa.cjs.js` | Node.js `require()` |
|
|
1193
|
+
| **UMD** | `dist/nexa.umd.js` | Universal (AMD, CJS, global) |
|
|
1194
|
+
| **IIFE** | `dist/nexa.iife.js` | Tags de script (`<script>`) |
|
|
1195
|
+
| **Types** | `dist/types/index.d.ts` | Declaraciones de tipos TypeScript |
|
|
1196
|
+
|
|
1197
|
+
---
|
|
1198
|
+
|
|
1199
|
+
## Desarrollo
|
|
1200
|
+
|
|
1201
|
+
### Pruebas
|
|
1202
|
+
|
|
1203
|
+
**157 tests en total**: 88 tests de HTTP Client + 69 tests de utilities
|
|
1204
|
+
|
|
1205
|
+
```bash
|
|
1206
|
+
# Ejecutar todos los tests
|
|
1207
|
+
npm test
|
|
1208
|
+
|
|
1209
|
+
# Watch mode
|
|
1210
|
+
npm run test:watch
|
|
1211
|
+
|
|
1212
|
+
# Test coverage
|
|
1213
|
+
npm run test:coverage
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
Los tests usan **Vitest** (globals mode) con BDD style (`describe`/`it`/`expect`).
|
|
1217
|
+
|
|
1218
|
+
### Build
|
|
1219
|
+
|
|
1220
|
+
```bash
|
|
1221
|
+
# Generar distribución
|
|
1222
|
+
npm run build
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
**Configuración de build:**
|
|
1226
|
+
|
|
1227
|
+
- **Formatos**: ES, CommonJS, UMD, IIFE
|
|
1228
|
+
- **Minificación**: OXC (ultra-rápido)
|
|
1229
|
+
- **Type Definitions**: Bundled en `/dist/types`
|
|
1230
|
+
- **Tree-shakeable**: Solo importa lo que usas
|
|
1231
|
+
- **Externas**: `fs` (Node.js only para `streamToFile`)
|
|
1232
|
+
|
|
1233
|
+
**Output:**
|
|
1234
|
+
|
|
1235
|
+
```
|
|
1236
|
+
dist/
|
|
1237
|
+
├── nexa.es.js (24.9 KB, gzip: 7.53 KB)
|
|
1238
|
+
├── nexa.cjs.js (19.9 KB, gzip: 6.68 KB)
|
|
1239
|
+
├── nexa.umd.js (19.8 KB, gzip: 6.75 KB)
|
|
1240
|
+
├── nexa.iife.js (19.6 KB, gzip: 6.68 KB)
|
|
1241
|
+
└── types/ (Tipo definitivo .d.ts)
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
### Cobertura de Tests
|
|
1245
|
+
|
|
1246
|
+
**Cobertura General: 75.73%** — sólida cobertura de tests unitarios con mocking HTTP
|
|
1247
|
+
|
|
1248
|
+
| Componente | Cobertura | Detalles |
|
|
1249
|
+
| ----------- | ---------- | --------------------------------- |
|
|
1250
|
+
| HTTP Client | **80.85%** | 81.25% ramas, 73.43% funciones |
|
|
1251
|
+
| Types | **100%** | Cobertura perfecta de tipos |
|
|
1252
|
+
| Utils | **71.79%** | 66.66% ramas, 81.14% funciones |
|
|
1253
|
+
|
|
1254
|
+
**HTTP Client** (`test/http-client.test.ts`) — **88 tests**:
|
|
1255
|
+
|
|
1256
|
+
- ✓ Métodos core: create, GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS (7 tests)
|
|
1257
|
+
- ✓ Estrategias de reintentos & timeouts (3 tests)
|
|
1258
|
+
- ✓ Interceptores & disposal (5 tests)
|
|
1259
|
+
- ✓ Caché & validación (4 tests)
|
|
1260
|
+
- ✓ Type safety & extensiones (3 tests)
|
|
1261
|
+
- ✓ Paginación & polling (5 tests)
|
|
1262
|
+
- ✓ Manejo de tipos de respuesta: 8+ tipos + auto-detección (13 tests)
|
|
1263
|
+
- ✓ Detección de content-type binario: image/*, audio/*, video/*, octet-stream (5 tests)
|
|
1264
|
+
- ✓ Serialización de body: JSON, null, strings, Blob, URLSearchParams, ArrayBuffer, TypedArray, FormData, ReadableStream (7 tests)
|
|
1265
|
+
- ✓ Normalización de errores: TimeoutError, AbortError, TypeError, unknown, NETWORK_ERROR (5+ tests)
|
|
1266
|
+
- ✓ Gestión de peticiones: activeRequests, cancelAll, clearCache (2 tests)
|
|
1267
|
+
- ✓ Verificación de exports: todas las 8 categorías de exports (8 tests)
|
|
1268
|
+
- ✓ Integración de plugins: LoggerPlugin, MetricsPlugin event handlers (7 tests)
|
|
1269
|
+
- ✓ Configuración avanzada: null body, direct Blob, abort messages (5+ tests)
|
|
1270
|
+
|
|
1271
|
+
**Utilities** (`test/utils.test.ts`) — **69 tests**:
|
|
1272
|
+
|
|
1273
|
+
- ✓ Validadores: schema, required fields, type checks (4 tests)
|
|
1274
|
+
- ✓ Transformadores: snake↔camel case, flatten, projection, wrapper (5 tests)
|
|
1275
|
+
- ✓ Estrategias de Reintentos: Aggressive, Conservative, Circuit Breaker (10 tests)
|
|
1276
|
+
- ✓ Timeout & Retry: withTimeout, retry function (6 tests)
|
|
1277
|
+
- ✓ Caché: CacheStore CRUD, TTL expiry (5 tests)
|
|
1278
|
+
- ✓ Deduplicación: RequestDeduplicator sharing, cleanup (3 tests)
|
|
1279
|
+
- ✓ Pipeline de Middleware: ordering, next() guard, legacy pipeline (3 tests)
|
|
1280
|
+
- ✓ Cache Middleware: GET caching, POST bypass (2 tests)
|
|
1281
|
+
- ✓ Dedup Middleware: GET dedup, POST bypass (2 tests)
|
|
1282
|
+
- ✓ Generics Tipados: TypedResponse, TypedObservable (map/filter), Defer, type guards, branded types (9 tests)
|
|
1283
|
+
- ✓ Plugins: PluginManager, LoggerPlugin, MetricsPlugin, CachePlugin, DedupePlugin (5 tests)
|
|
1284
|
+
|
|
1285
|
+
### Limitaciones de Cobertura & Techo Realista
|
|
1286
|
+
|
|
1287
|
+
La cobertura de tests unitarios se estabiliza alrededor del **75-80%** debido a limitaciones inherentes del mocking:
|
|
1288
|
+
|
|
1289
|
+
**¿Por qué no 95%?**
|
|
1290
|
+
- **Características de streaming** (~3-5% gap): Download progress tracking usa `ReadableStream.getReader()` que requiere HTTP real — no mockeable con `fetch-mock`
|
|
1291
|
+
- **Ejemplos de utilities** (~5-10% gap): Patrones de middleware y código de referencia no son ejercitados activamente en producción
|
|
1292
|
+
- **Archivos solo-export** (~2-3% gap): `http-client/index.ts` verificado vía validación de imports, no testeable por unidad
|
|
1293
|
+
|
|
1294
|
+
**Máximos realistas:**
|
|
1295
|
+
- Unit tests + mocks: **~80-85%** techo (actual: 75.73%)
|
|
1296
|
+
- Tests de integración requeridos: llegaría a 90%+ pero fuera del alcance del proyecto
|
|
1297
|
+
|
|
1298
|
+
El 75.73% de cobertura representa testing exhaustivo de todas las **rutas de código de producción** alcanzables vía mocks HTTP.
|
|
1299
|
+
|
|
1300
|
+
---
|
|
1301
|
+
|
|
1302
|
+
## Licencia
|
|
1303
|
+
|
|
1304
|
+
MIT © [John Andrade](mailto:johnandrade@bereasoft.com) — [@bereasoftware](https://github.com/Berea-Soft)
|