@bereasoftware/nexa 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,12 +6,12 @@
6
6
  </p>
7
7
 
8
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>
9
+ <a href="#tests"><img src="https://img.shields.io/badge/Tests-233_passing-brightgreen?style=for-the-badge" alt="Tests" /></a>
10
+ <a href="#test-coverage"><img src="https://img.shields.io/badge/Coverage-72%25-orange?style=for-the-badge" alt="Coverage" /></a>
11
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
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
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" />
14
+ <img src="https://img.shields.io/badge/Node-20%2B-success?style=for-the-badge" alt="Node" />
15
15
  <img src="https://img.shields.io/badge/TypeScript-5.x-3178C6?style=for-the-badge" alt="TypeScript" />
16
16
  <img src="https://img.shields.io/badge/Dependencies-Zero-brightgreen?style=for-the-badge" alt="Dependencies" />
17
17
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="License" /></a>
@@ -48,6 +48,13 @@
48
48
  | Validators & transformers | ❌ | ❌ | ✅ |
49
49
  | Response duration tracking | ❌ | ❌ | ✅ |
50
50
  | Smart response type detection | ❌ | ✅ | ✅ |
51
+ | Request transformation (transformRequest) | ❌ | ✅ | ✅ |
52
+ | Credentials policy | ✅ | ✅ | ✅ |
53
+ | Adapter pattern (mocking/testing) | ❌ | ✅ | ✅ |
54
+ | Automatic FormData conversion | ❌ | ❌ | ✅ |
55
+ | Enhanced error context (request/response/config) | ❌ | ✅ | ✅ |
56
+ | Differentiated timeouts (connection vs response) | ❌ | ❌ | ✅ |
57
+ | Debug logging (development mode) | ❌ | ❌ | ✅ |
51
58
  | Tree-shakeable | ✅ | ❌ | ✅ |
52
59
 
53
60
  ---
@@ -64,6 +71,10 @@
64
71
  - [Path Parameters](#path-parameters)
65
72
  - [Query Parameters](#query-parameters)
66
73
  - [Auto Body Serialization](#auto-body-serialization)
74
+ - [Request Transformation (transformRequest)](#request-transformation-transformrequest)
75
+ - [Credentials Policy](#credentials-policy)
76
+ - [Adapter Pattern](#adapter-pattern)
77
+ - [Transport Configuration](#transport-configuration)
67
78
  - [Response Types](#response-types)
68
79
  - [Timeout](#timeout)
69
80
  - [Retry Strategies](#retry-strategies)
@@ -91,9 +102,12 @@
91
102
  - [Streaming](#streaming)
92
103
  - [Typed Generics](#typed-generics)
93
104
  - [Error Handling](#error-handling)
105
+ - [Enhanced Error Context](#enhanced-error-context)
94
106
  - [API Reference](#api-reference)
95
107
  - [Build Formats](#build-formats)
96
108
  - [Development](#development)
109
+ - [Contributing](#contributing)
110
+ - [Security](#security)
97
111
  - [License](#license)
98
112
 
99
113
  ---
@@ -207,6 +221,8 @@ const client = createHttpClient({
207
221
  | `maxConcurrent` | `number` | `0` (unlimited) | Max concurrent requests |
208
222
  | `defaultResponseType` | `ResponseType` | `'auto'` | Default response parsing strategy |
209
223
  | `defaultHooks` | `RequestHooks` | `{}` | Default lifecycle hooks for all requests |
224
+ | `debug` | `boolean \| 'verbose'` | `undefined` | Enable debug logging for requests/responses. `true` for basic logs, `'verbose'` for detailed logs. |
225
+ | `logger` | `(message: string, data?: unknown) => void` | `undefined` | Custom logger function. If provided, replaces the default console.log with custom logging. |
210
226
 
211
227
  ---
212
228
 
@@ -289,6 +305,8 @@ Nexa automatically detects and serializes the request body:
289
305
  | `ArrayBuffer` | Passed as-is | `application/octet-stream` |
290
306
  | `ReadableStream` | Passed as-is | `application/octet-stream` |
291
307
 
308
+ When `autoFormData` is enabled (default), objects containing `File` or `Blob` instances are automatically converted to `FormData`. This is useful for uploading files without manually creating `FormData` instances.
309
+
292
310
  ```typescript
293
311
  // JSON (automatic)
294
312
  await client.post("/users", { name: "John" });
@@ -305,6 +323,172 @@ await client.post(
305
323
  );
306
324
  ```
307
325
 
326
+ ### Request Transformation (transformRequest)
327
+
328
+ Nexa supports request transformation similar to axios's `transformRequest`. You can provide a function or array of functions that transform the request body before serialization. The transformation is applied after interceptors and before serialization.
329
+
330
+ ```typescript
331
+ // Global configuration
332
+ const client = createHttpClient({
333
+ transformRequest: [(data, headers) => {
334
+ // Add timestamp to all requests
335
+ if (data && typeof data === 'object') {
336
+ return { ...data, timestamp: Date.now() };
337
+ }
338
+ return data;
339
+ }]
340
+ });
341
+
342
+ // Per-request configuration
343
+ await client.post('/api', { foo: 'bar' }, {
344
+ transformRequest: [(data) => ({ ...data, extra: 'value' })]
345
+ });
346
+ ```
347
+
348
+ ### Credentials Policy
349
+
350
+ Nexa supports the standard fetch `credentials` option as well as axios-compatible `withCredentials` boolean. This controls whether cookies and other credentials are sent with cross-origin requests.
351
+
352
+ ```typescript
353
+ // Using fetch-style credentials
354
+ await client.get('/api', { credentials: 'include' });
355
+
356
+ // Using axios-style withCredentials (true = 'include', false = 'same-origin')
357
+ await client.post('/api', data, { withCredentials: true });
358
+
359
+ // Global configuration
360
+ const client = createHttpClient({
361
+ credentials: 'same-origin', // Default: 'omit'
362
+ // or
363
+ withCredentials: true, // Equivalent to credentials: 'include'
364
+ });
365
+ ```
366
+
367
+ Priority: `credentials` overrides `withCredentials` if both are specified.
368
+
369
+ ### Adapter Pattern
370
+
371
+ Nexa supports custom adapters for mocking, testing, or integrating with different environments (Cloudflare Workers, Node.js, etc.). The adapter has the same signature as the global `fetch` function.
372
+
373
+ ```typescript
374
+ // Mock adapter for testing
375
+ const mockAdapter = async (input: RequestInfo, init?: RequestInit) => {
376
+ return new Response(JSON.stringify({ mock: true }), { status: 200 });
377
+ };
378
+
379
+ // Global adapter
380
+ const client = createHttpClient({
381
+ adapter: mockAdapter,
382
+ });
383
+
384
+ // Per-request adapter (overrides global)
385
+ await client.get('/api', { adapter: mockAdapter });
386
+ ```
387
+
388
+ Adapters can be used to intercept requests before they reach the network, enabling easy testing without actual HTTP calls.
389
+
390
+ ### Mocking Utilities (axios-mock-adapter style)
391
+
392
+ For more sophisticated testing scenarios, Nexa provides a mocking utility similar to `axios-mock-adapter`. This allows you to configure mock responses for specific routes and HTTP methods.
393
+
394
+ ```typescript
395
+ import { createHttpClient } from '@bereasoftware/nexa';
396
+ import { createMockClient } from '@bereasoftware/nexa/testing';
397
+
398
+ const client = createHttpClient({ baseURL: 'https://api.example.com' });
399
+ const mockClient = createMockClient(client);
400
+
401
+ // Configure mock responses
402
+ mockClient.onGet('/users').reply(200, [{ id: 1, name: 'John' }]);
403
+ mockClient.onPost('/users').reply(201, { id: 2, name: 'Jane' });
404
+ mockClient.onPut('/users/1').reply(200, { id: 1, name: 'Updated' });
405
+ mockClient.onDelete('/users/1').reply(204);
406
+
407
+ // Network error simulation
408
+ mockClient.onGet('/error').networkError('Network failure');
409
+
410
+ // Use the mock-enabled client for requests
411
+ const result = await mockClient.client.get('/users');
412
+ if (result.ok) {
413
+ console.log(result.value.data); // [{ id: 1, name: 'John' }]
414
+ }
415
+
416
+ // Advanced: reply once, then fall through
417
+ mockClient.onGet('/once').replyOnce(200, { data: 'first' });
418
+ // Second call to same route will not match (unless passthrough enabled)
419
+
420
+ // Advanced: function-based responses
421
+ mockClient.onGet('/dynamic').reply((config) => ({
422
+ status: 200,
423
+ data: { url: config.url, query: config.query },
424
+ }));
425
+
426
+ // Reset mock routes between tests
427
+ mockClient.reset();
428
+ ```
429
+
430
+ **Key features:**
431
+ - **Fluent API**: `onGet(url).reply(status, data)` similar to axios-mock-adapter
432
+ - **URL patterns**: Support string exact match or RegExp
433
+ - **Response functions**: Dynamic responses based on request config
434
+ - **Call limits**: `replyOnce()` for one-time mock
435
+ - **Network errors**: Simulate network failures with `networkError()`
436
+ - **Timeout simulation**: `timeout()` method for timeout testing
437
+ - **Passthrough mode**: Optionally forward unmatched requests to real adapter
438
+ - **Base URL support**: Automatically strips baseURL when matching
439
+
440
+ **Options** for `createMockClient`:
441
+ - `passthrough`: Forward unmatched requests to original adapter (default: `false`)
442
+ - `baseURL`: Base URL to strip when matching routes
443
+ - `delay`: Default delay for all responses (ms)
444
+
445
+ ### Transport Configuration
446
+
447
+ Nexa supports multiple transport layers for different environments. By default, it uses the global `fetch` API (available in browsers and Node.js 18+). For advanced Node.js scenarios, you can use the native HTTP/1.1 or HTTP/2 modules with keep-alive, connection pooling, and other Node-specific optimizations.
448
+
449
+ ```typescript
450
+ import { createHttpClient } from '@bereasoftware/nexa';
451
+
452
+ // Use Node.js HTTP/1.1 with keep-alive and connection pooling
453
+ const client = createHttpClient({
454
+ transport: 'node', // 'fetch' (default), 'node', or 'http2'
455
+ nodeOptions: {
456
+ keepAlive: true,
457
+ maxSockets: 50,
458
+ maxFreeSockets: 10,
459
+ maxRequestsPerSocket: 0, // unlimited
460
+ timeout: 60000,
461
+ },
462
+ });
463
+
464
+ // Use HTTP/2 for better performance with multiplexing
465
+ const http2Client = createHttpClient({
466
+ transport: 'http2',
467
+ nodeOptions: {
468
+ http2Settings: {
469
+ enablePush: false,
470
+ },
471
+ },
472
+ });
473
+
474
+ // Override transport per request
475
+ await client.get('/api', {
476
+ transport: 'http2',
477
+ nodeOptions: { keepAlive: true },
478
+ });
479
+ ```
480
+
481
+ **Note:** Node transports (`'node'` and `'http2'`) are only available in Node.js environments. In browsers, they will fall back to `'fetch'` automatically.
482
+
483
+ **Available options in `nodeOptions`:**
484
+ - `keepAlive` (boolean): Enable keep-alive connections (default: `true`)
485
+ - `maxSockets` (number): Maximum sockets per host (default: `50`)
486
+ - `maxFreeSockets` (number): Maximum free sockets to keep open (default: `10`)
487
+ - `maxRequestsPerSocket` (number): Maximum requests per socket (default: `0` = unlimited)
488
+ - `timeout` (number): Socket timeout in milliseconds (default: `60000`)
489
+ - `http2` (boolean): Deprecated - use `transport: 'http2'` instead
490
+ - `http2Settings` (Record<string, unknown>): HTTP/2 specific settings (only for `transport: 'http2'`)
491
+
308
492
  ### Response Types
309
493
 
310
494
  Control how the response body is parsed:
@@ -349,10 +533,66 @@ const result = await client.get("/slow-endpoint", { timeout: 5000 });
349
533
  // Timeout produces a specific error code
350
534
  if (!result.ok && result.error.code === "TIMEOUT") {
351
535
  console.log("Request timed out");
352
- }
353
536
  ```
354
537
 
355
- ---
538
+ Nexa also supports differentiated timeouts for connection and response phases, going beyond axios/fetch's single timeout. You can specify timeouts as an object:
539
+
540
+ ```typescript
541
+ // Differentiated timeouts
542
+ await client.get('/api', {
543
+ timeout: {
544
+ connection: 3000, // 3 seconds to establish connection
545
+ response: 10000, // 10 seconds to receive complete response
546
+ total: 15000 // Optional total timeout (overrides connection/response)
547
+ }
548
+ });
549
+
550
+ // Backward compatibility: number still works (total timeout)
551
+ await client.get('/api', { timeout: 5000 });
552
+ ```
553
+
554
+ Error codes: `TIMEOUT` for connection/total timeouts, `RESPONSE_TIMEOUT` for response phase timeouts.
555
+
556
+ ### Debug Logging
557
+
558
+ Nexa provides axios-like debug logging for development. Enable it globally or per-request:
559
+
560
+ ```typescript
561
+ // Global debug logging
562
+ const client = createHttpClient({
563
+ debug: true, // basic logs
564
+ // debug: 'verbose', // detailed logs
565
+ });
566
+
567
+ // Per-request override
568
+ await client.get('/api', { debug: 'verbose' });
569
+ ```
570
+
571
+ Logs include request method/URL, response status, duration, errors, retries, and cache hits. With `verbose` mode, you also see transformed requests, interceptor outputs, and response data.
572
+
573
+ #### Custom Logger
574
+
575
+ You can provide a custom logger function to redirect logs to your preferred logging system:
576
+
577
+ ```typescript
578
+ const client = createHttpClient({
579
+ debug: true,
580
+ logger: (message, data) => {
581
+ // Send to your logging service
582
+ myLogger.info(message, data);
583
+ },
584
+ });
585
+
586
+ // Request-level logger override
587
+ await client.post('/api', data, {
588
+ debug: true,
589
+ logger: (msg, data) => console.warn(msg, data)
590
+ });
591
+ ```
592
+
593
+ The logger receives two arguments: the log message (prefixed with `[Nexa HTTP]`) and optional data object.
594
+
595
+ ---
356
596
 
357
597
  ## Retry Strategies
358
598
 
@@ -367,6 +607,27 @@ const result = await client.get("/unstable-api", {
367
607
  // Retries up to 3 times with exponential backoff + jitter
368
608
  ```
369
609
 
610
+ ### Custom Retry Condition
611
+
612
+ You can provide a custom condition function to decide which errors should be retried:
613
+
614
+ ```typescript
615
+ const result = await client.get('/api', {
616
+ retry: {
617
+ maxAttempts: 3,
618
+ backoffMs: 1000,
619
+ on: (error) => {
620
+ // Retry only on network errors or 5xx status
621
+ return error.code === 'NETWORK_ERROR' ||
622
+ (error.status >= 500 && error.status < 600) ||
623
+ error.message.includes('ECONNRESET')
624
+ }
625
+ }
626
+ });
627
+ ```
628
+
629
+ The `on` function receives the `HttpErrorDetails` and attempt number, and returns `true` if the request should be retried. If `on` is not provided, the default condition (5xx status, network errors, timeouts) is used.
630
+
370
631
  ### AggressiveRetry
371
632
 
372
633
  Retries all errors up to max attempts with minimal delay:
@@ -1088,6 +1349,22 @@ deferred.reject(new Error("failed"));
1088
1349
  | `MAX_RETRIES` | All retry attempts exhausted |
1089
1350
  | `UNKNOWN_ERROR` | Unclassified error |
1090
1351
 
1352
+ ### Enhanced Error Context
1353
+
1354
+ Nexa provides rich error context similar to axios, including the original request, response, and configuration in error objects. This makes debugging easier.
1355
+
1356
+ ```typescript
1357
+ const result = await client.get('/api');
1358
+ if (!result.ok) {
1359
+ const { request, response, config } = result.error;
1360
+ console.log('Failed request URL:', request?.url);
1361
+ console.log('Response status:', response?.status);
1362
+ console.log('Config timeout:', config?.timeout);
1363
+ }
1364
+ ```
1365
+
1366
+ All errors now include `request`, `response` (if available), and `config` properties.
1367
+
1091
1368
  ### HttpError Class
1092
1369
 
1093
1370
  ```typescript
@@ -1199,7 +1476,7 @@ Nexa ships in multiple module formats:
1199
1476
 
1200
1477
  ### Tests
1201
1478
 
1202
- **157 tests in total**: 88 HTTP Client tests + 69 utilities tests
1479
+ **205 tests in total**: 88 HTTP Client tests + 69 utilities tests + 24 mock tests + 4 Node adapter tests + 20 additional tests
1203
1480
 
1204
1481
  ```bash
1205
1482
  # Run all tests
@@ -1242,13 +1519,14 @@ dist/
1242
1519
 
1243
1520
  ### Test Coverage
1244
1521
 
1245
- **Overall Coverage: 75.73%** — solid unit test coverage with mock-based HTTP testing
1522
+ **Overall Coverage: 71.4%** — solid unit test coverage with mock-based HTTP testing
1246
1523
 
1247
1524
  | Component | Coverage | Details |
1248
1525
  | ----------- | ---------- | --------------------------------- |
1249
- | HTTP Client | **80.85%** | 81.25% branches, 73.43% functions |
1526
+ | HTTP Client | **66.7%** | 67.3% branches, 67.0% functions |
1250
1527
  | Types | **100%** | Perfect type coverage |
1251
- | Utils | **71.79%** | 66.66% branches, 81.14% functions |
1528
+ | Testing | **93.7%** | 85.1% branches, 95.8% functions |
1529
+ | Utils | **71.8%** | 66.7% branches, 81.1% functions |
1252
1530
 
1253
1531
  **HTTP Client** (`test/http-client.test.ts`) — **88 tests**:
1254
1532
 
@@ -1279,10 +1557,18 @@ dist/
1279
1557
  - \u2713 Cache Middleware: GET caching, POST bypass (2 tests)
1280
1558
  - \u2713 Dedup Middleware: GET dedup, POST bypass (2 tests)
1281
1559
  - \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.
1560
+ - \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: 71.4%)\n- Integration tests required: Would reach 90%+ but beyond this project\u2019s scope\n\nThe 71.4% coverage represents comprehensive testing of all **production code paths** that can be reached via HTTP mocks.
1283
1561
 
1284
1562
  ---
1285
1563
 
1564
+ ## Contributing
1565
+
1566
+ Want to contribute to Nexa? Please read our [contributing guide](CONTRIBUTING.md) and [code of conduct](CODE_OF_CONDUCT.md).
1567
+
1568
+ ## Security
1569
+
1570
+ To report security vulnerabilities, see our [security policy](SECURITY.md).
1571
+
1286
1572
  ## License
1287
1573
 
1288
1574
  MIT © [John Andrade](mailto:johnandrade@bereasoft.com) — [@bereasoftware](https://github.com/Berea-Soft)