npm package: @pingpong-js/fetch
Description: Universal HTTP client for Node.js and browsers with automatic environment detection
Author: Devendra Pratap
Latest version: 1.0.2

# 🏓 @pingpong-js/fetch

> Universal HTTP client for Node.js and browsers with automatic environment detection

[![npm version](https://img.shields.io/npm/v/@pingpong-js/fetch)](https://www.npmjs.com/package/@pingpong-js/fetch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A lightweight, universal HTTP client that automatically detects your environment and uses the optimal implementation:
- **Node.js**: Uses [`undici`](https://github.com/nodejs/undici) for high performance
- **Browser**: Uses native `fetch()` API

## ✨ Features

- 🌐 **Universal** - Works seamlessly in Node.js and browsers
- ⚡ **Fast** - Uses `undici` in Node.js, native `fetch` in browsers
- 🔄 **Zero Config** - Automatic environment detection
- 📦 **Small** - Minimal dependencies (~5-6KB gzipped), tree-shakeable, optimized
- 🎯 **Type-Safe** - Full TypeScript support with generics (`get<T>`, `post<T>`, etc.)
- 📊 **Axios-style `response.data`** - Auto-parsed JSON data, no more `.then(r => r.json())` (v1.4.0)
- 🎣 **Event Listeners** - `onRequest`, `onResponse`, `onError`, `onRetry`, `onAbort` hooks (v1.4.0)
- 🔧 **Query Parameters** - Built-in query param support at client and request level (v1.2.0)
- 📋 **Case-Insensitive Headers** - RFC 2616 compliant header access (v1.2.0)- 🍪 **Credentials Control** - Control cookie sending with `credentials` option (Browser)- 🔁 **Retry Logic** - Built-in retry with exponential backoff
- 📄 **Response Methods** - Chainable methods: `.json()`, `.text()`, `.raw()`, `.ok()`, `.isError()`, `.header()`
- 📡 **Streaming** - Support for streaming large files and responses
- 🛠️ **Convenience Methods** - `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()`, `.head()`, `.options()`
- ⏱️ **Timeout Support** - Configurable timeouts at client and request level
- 🔀 **Proxy Support** - HTTP proxy support (Node.js)
- 🚫 **Request Cancellation** - AbortSignal support for cancelling requests
- 🔁 **Redirect Handling** - Automatic redirect following with configurable limits

## 📦 Installation

```bash
npm install @pingpong-js/fetch
```

### 🔌 Optional Plugins (Lodash-style)

Install only the plugins you need:

```bash
# Smart caching (memory, localStorage, IndexedDB)
npm install @pingpong-js/plugin-cache

# Advanced retry, circuit breaker, rate limiting
npm install @pingpong-js/plugin-retry

# Zod schema validation for type-safe responses
npm install @pingpong-js/plugin-zod
```

```typescript
import pingpong from '@pingpong-js/fetch';
import { createCache } from '@pingpong-js/plugin-cache';
import { createCircuitBreaker } from '@pingpong-js/plugin-retry';
import { withZod } from '@pingpong-js/plugin-zod';
```

## 🚀 Quick Start

```typescript
import pingpong from '@pingpong-js/fetch';

// Simple GET request - JSON is auto-parsed! (Axios-style)
const response = await pingpong.get('https://api.mockly.codes/users/1');
console.log(response.data);        // Already parsed JSON!
console.log(response.data.name);   // Access properties directly

// Type-safe with generics
interface User {
  id: number;
  name: string;
  email: string;
}
const userRes = await pingpong.get<User>('https://api.mockly.codes/users/1');
console.log(userRes.data.name);    // TypeScript knows this is string!

// POST with JSON (automatically stringified, response auto-parsed)
const newUser = await pingpong.post<User>('https://api.mockly.codes/users', {
  name: 'John Doe',
  email: 'john@example.com'
});
console.log(newUser.data.id);      // New user's ID

// For non-JSON responses, use json: false
const textRes = await pingpong.get('https://example.com/robots.txt', { json: false });
console.log(textRes.body);         // Raw text response
console.log(textRes.data);         // null (not parsed)

// Legacy .json() method still works
const user = await pingpong.get('https://api.mockly.codes/users/1').then(r => r.json());
```

### Custom Configuration

```typescript
import pingpong from '@pingpong-js/fetch';

// Create a custom client from pingpong
const api = pingpong.create({
  baseURL: 'https://api.mockly.codes',
  timeout: 5000,
  params: { api_key: 'secret' }  // Default params for all requests
});

// Use with query parameters
const response = await api.get('/users', {
  params: { page: 1, limit: 10 }  // Merged with client params
});

// Or import HttpClient directly for full control
import { HttpClient } from '@pingpong-js/fetch';
const customClient = new HttpClient({ baseURL: 'https://other-api.com' });
```

## 📊 Performance

- **Bundle Size**: ~5-6KB gzipped (25% smaller than axios)
- **Throughput**: ~4,250 req/s average
- **Latency**: ~0.24ms average response time
- **Build Time**: Optimized with stripped source maps and comments

## 📚 Table of Contents

- [Features](#-features)
- [Performance](#-performance)
- [Installation](#-installation)
- [Quick Start](#-quick-start)
- [Examples](#-examples)
- [API Reference](#-api-reference)
- [Environment Detection](#-environment-detection)
- [License](#-license)

## 📖 Examples

Check out the [examples](./examples) directory for complete working examples of all features:

### Node.js Examples
- **[Basic GET](./examples/basic-get.js)** - Simple GET request
- **[POST with JSON](./examples/post-json.js)** - Send JSON data
- **[Headers & Auth](./examples/headers-auth.js)** - Authentication and custom headers
- **[Response Methods](./examples/response-methods.js)** - .json(), .text(), .ok(), etc.
- **[Convenience Methods](./examples/convenience-methods.js)** - .get(), .post(), .put(), .delete()
- **[FormData Upload](./examples/formdata-upload.js)** - File uploads with FormData
- **[Error Handling](./examples/error-handling.js)** - Handle errors gracefully
- **[Retry Logic](./examples/retry-logic.js)** - Automatic retry with backoff
- **[Timeout](./examples/timeout.js)** - Configure timeouts
- **[Abort Signal](./examples/abort-signal.js)** - Cancel requests
- **[Proxy Support](./examples/proxy.js)** - Route through proxy
- **[Credentials](./examples/credentials-example.js)** - Cookie control (Browser)

### Browser Examples
- **[Browser Usage](./examples/browser-fetch.html)** - Interactive browser example
- **[Environment Detection](./examples/environment-detection.js)** - Automatic detection

See [examples/README.md](./examples/README.md) for complete example documentation.

## 📖 API Reference

### pingpong

The default pre-configured HTTP client instance, ready to use out of the box.

```typescript
import pingpong from '@pingpong-js/fetch';

// Use directly
const response = await pingpong.get('https://api.example.com/users');

// Create a custom client with options
const api = pingpong.create({
  baseURL: 'https://api.example.com',
  timeout: 5000
});
```

### HttpClient

For full control, you can instantiate `HttpClient` directly:

```typescript
import { HttpClient } from '@pingpong-js/fetch';
const client = new HttpClient(options?: HttpClientOptions);
```

### Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `baseURL` | `string` | `undefined` | Base URL prepended to relative URLs |
| `params` | `Record<string, any>` | `undefined` | Default query parameters for all requests (v1.2.0) |
| `timeout` | `number` | `undefined` | Default request timeout in milliseconds |
| `headers` | `HttpHeaders` | `{}` | Default headers for all requests |
| `proxy` | `string` | `undefined` | HTTP proxy URL (Node.js only) |
| `followRedirects` | `boolean` | `true` | Automatically follow redirects |
| `maxRedirects` | `number` | `10` | Maximum number of redirects to follow |
| `credentials` | `'omit' \| 'same-origin' \| 'include'` | `undefined` | Controls cookie sending behavior (Browser only) |
| `retries` | `number` | `0` | Default number of retry attempts |
| `retryDelay` | `number` | `1000` | Default delay between retries (ms, uses exponential backoff) |
| `retryCondition` | `(response: HttpResponse) => boolean` | See below | Function to determine if request should be retried |
| `json` | `boolean` | `true` | Auto-parse JSON responses and populate `data` property (v1.4.0) |

**Default Retry Condition:**
```typescript
(response) => response.status === 0 || response.status >= 500 || response.status === 429
```
Retries on network errors (status 0), server errors (5xx), and rate limiting (429).

### Methods

#### Core Methods

##### `send(request: HttpRequest, options?: RequestOptions): Promise<HttpResponse>`

Send a custom HTTP request.

```typescript
const response = await pingpong.send({
  method: 'GET',
  url: 'https://api.mockly.codes/data',
  headers: { 'Authorization': 'Bearer token' }
});
```

##### `sendStream(request: HttpRequest, options?: RequestOptions): Promise<StreamResponse>`

Send a request and return a stream instead of buffering the response.

```typescript
const { stream, status, headers } = await pingpong.sendStream({
  method: 'GET',
  url: 'https://api.mockly.codes/large-file'
});
```

#### HTTP Methods

All HTTP methods accept an optional `RequestOptions` parameter to override client defaults.

```typescript
// GET request returning response with chainable methods
pingpong.get(url: string, options?: RequestOptions): Promise<HttpResponse>

// GET returning stream
pingpong.getStream(url: string, options?: RequestOptions): Promise<StreamResponse>

// POST request (data automatically stringified if object)
pingpong.post(url: string, data?: any, options?: RequestOptions): Promise<HttpResponse>

// PUT request
pingpong.put(url: string, data?: any, options?: RequestOptions): Promise<HttpResponse>

// PATCH request
pingpong.patch(url: string, data?: any, options?: RequestOptions): Promise<HttpResponse>

// DELETE request
pingpong.delete(url: string, options?: RequestOptions): Promise<HttpResponse>

// HEAD request
pingpong.head(url: string, options?: RequestOptions): Promise<HttpResponse>

// OPTIONS request
pingpong.options(url: string, options?: RequestOptions): Promise<HttpResponse>
```

#### Event Listeners

Register listeners for various request lifecycle events:

```typescript
// Called before each request (can modify request)
pingpong.onRequest(listener: (req: HttpRequest) => HttpRequest | void): () => void

// Called after each response (can modify response)
pingpong.onResponse(listener: (res: HttpResponse) => HttpResponse | void): () => void

// Called when a request fails
pingpong.onError(listener: (error: Error, req: HttpRequest) => void): () => void

// Called before each retry attempt
pingpong.onRetry(listener: (attempt: number, req: HttpRequest, res: HttpResponse) => void): () => void

// Called when a request is aborted
pingpong.onAbort(listener: (req: HttpRequest) => void): () => void

// Remove listeners
pingpong.off(event?: string): void  // Clear specific or all listeners

// Get listener count
pingpong.listenerCount(event: string): number
```

#### Factory Method

##### `create(options: HttpClientOptions): HttpClient`

Create a new client instance with custom options.

```typescript
const api = pingpong.create({
  baseURL: 'https://api.mockly.codes',
  timeout: 5000
});
```

### Response Methods

All HTTP methods return an `HttpResponse` with these helper methods:

##### `ok(): boolean`

Check if status is 2xx (200-299).

```typescript
const response = await pingpong.get('/data');
if (response.ok()) {
  console.log('Success!');
}
```

##### `isError(): boolean`

Check if status is 4xx or 5xx.

```typescript
const response = await pingpong.get('/data');
if (response.isError()) {
  console.error('Request failed:', response.status);
}
```

##### `redirected(): boolean`

Check if response was a redirect (3xx).

```typescript
const response = await pingpong.get('/redirect');
console.log(response.redirected()); // true
```

##### `json<T>(): T`

Parse response body as JSON.

```typescript
const user = await pingpong.get('https://api.mockly.codes/users/1').then(r => r.json());
```

##### `text(): string`

Get response body as text.

```typescript
const html = await pingpong.get('https://example.com').then(r => r.text());
```

##### `raw(): HttpResponseBase`

Get the raw response object (without methods).

```typescript
const response = await pingpong.get('/data');
const raw = response.raw();
```

##### `header(name: string): string | undefined`

Get response header value (case-insensitive, RFC 2616 compliant).

```typescript
const response = await pingpong.get('/data');

// All of these return the same value
response.header('content-type');   // 'application/json'
response.header('Content-Type');   // 'application/json'
response.header('CONTENT-TYPE');   // 'application/json'
```

### Types

#### HttpRequest

```typescript
interface HttpRequest {
  method: HttpMethod;              // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
  url: string;                     // Request URL
  headers?: HttpHeaders;            // Request headers
  body?: string | Uint8Array | Blob | FormData;  // Request body
  timeout?: number;                // Request timeout (ms)
  signal?: AbortSignal;            // Abort signal for cancellation
  proxy?: string;                  // Proxy URL (Node.js only)
  followRedirects?: boolean;       // Follow redirects
  maxRedirects?: number;           // Max redirects to follow
  credentials?: 'omit' | 'same-origin' | 'include';  // Cookie behavior (Browser)
  retries?: number;                // Retry attempts
  retryDelay?: number;             // Retry delay (ms)
  validateStatus?: (status: number) => boolean;  // Status validation
}
```

#### HttpResponse

```typescript
interface HttpResponse<T = any> {
  status: number;                  // HTTP status code
  statusText: string;              // HTTP status text
  headers: HttpHeaders;            // Response headers
  body: string;                    // Response body (as string)
  data: T;                         // Auto-parsed JSON data (v1.4.0)
  time: number;                    // Request duration (ms)
  size: number;                    // Response size (bytes)

  // Chainable methods
  raw(): HttpResponseBase;         // Get raw response
  json<R = T>(): R;                // Parse as JSON (legacy)
  text(): string;                  // Get body as text
  header(name: string): string | undefined;  // Get single header (case-insensitive)
  ok(): boolean;                   // Check if 2xx
  redirected(): boolean;           // Check if 3xx
  isError(): boolean;              // Check if 4xx/5xx
}
```

#### StreamResponse

```typescript
interface StreamResponse {
  status: number;                  // HTTP status code
  statusText: string;              // HTTP status text
  headers: HttpHeaders;            // Response headers
  stream: ReadableStream<Uint8Array> | NodeJS.ReadableStream;  // Response stream
  time: number;                    // Request duration (ms)
}
```

#### RequestOptions

All options from `HttpClientOptions` can be overridden per-request via `RequestOptions`:

```typescript
interface RequestOptions {
  params?: Record<string, any>;   // Query parameters (merged with client params)
  timeout?: number;
  headers?: HttpHeaders;
  signal?: AbortSignal;
  proxy?: string;                 // Node.js only
  followRedirects?: boolean;
  maxRedirects?: number;
  credentials?: 'omit' | 'same-origin' | 'include';  // Cookie behavior (Browser)
  retries?: number;
  retryDelay?: number;
  retryCondition?: (response: HttpResponse) => boolean;
  json?: boolean;                 // Auto-parse JSON (default: true, v1.4.0)
}
```

## 💡 Examples

### Basic Usage

```typescript
import pingpong from '@pingpong-js/fetch';

// GET request with chainable response methods
const response = await pingpong.get('https://api.mockly.codes/users');
console.log(response.status);     // 200
console.log(response.text());     // Response body as string
console.log(response.time);       // Request duration in ms
console.log(response.size);       // Response size in bytes
console.log(response.ok());       // true (status 2xx)
console.log(response.getHeaders());  // Response headers
```

### JSON Parsing

```typescript
import pingpong from '@pingpong-js/fetch';

interface User {
  id: number;
  name: string;
  email: string;
}

// NEW in v1.4.0: Auto-parsed JSON in response.data (Axios-style)
const response = await pingpong.get<User>('https://api.mockly.codes/users/1');
console.log(response.data.name);   // Already parsed! TypeScript knows it's User

// POST with auto-parsed response
const newUser = await pingpong.post<User>('https://api.mockly.codes/users', {
  name: 'John Doe',
  email: 'john@mockly.codes'
});
console.log(newUser.data.id);      // Response is auto-parsed

// For non-JSON responses, disable auto-parsing
const htmlResponse = await pingpong.get('https://example.com', { json: false });
console.log(htmlResponse.body);    // Raw HTML
console.log(htmlResponse.data);    // null (not parsed)

// Legacy .json() method still works
const user = await pingpong.get('https://api.mockly.codes/users/1').then(r => r.json<User>());
console.log(user.name);  // Works the same as before
```

### Retry Logic

```typescript
import pingpong from '@pingpong-js/fetch';

// Configure retry at client level using pingpong.create()
const client = pingpong.create({
  retries: 3,              // Retry up to 3 times
  retryDelay: 1000,        // Initial delay: 1 second (exponential backoff)
  retryCondition: (response) => {
    // Retry on server errors or rate limits
    return response.status >= 500 || response.status === 429;
  }
});

// All requests will automatically retry
const response = await client.get('https://api.mockly.codes/flaky-endpoint');

// Override retry for specific request
const response2 = await client.get('https://api.mockly.codes/data', {
  retries: 5,              // Retry 5 times for this request
  retryDelay: 2000,        // 2 second initial delay
  retryCondition: (res) => res.status === 503  // Only retry on 503
});

// Disable retry for specific request
const response3 = await client.get('https://api.mockly.codes/data', {
  retries: 0  // No retries
});
```

**Retry Behavior:**
- Uses exponential backoff: delay × 2^attempt (1s, 2s, 4s, 8s...)
- Respects abort signals (won't retry if cancelled)
- Default condition: retries on status 0 (network error), 5xx (server error), or 429 (rate limit)

### Streaming

```typescript
import pingpong from '@pingpong-js/fetch';

// Stream large file download (Node.js)
const streamResponse = await pingpong.getStream('https://example.com/large-file.zip');

// Node.js: ReadableStream
const stream = streamResponse.stream as NodeJS.ReadableStream;
const fs = require('fs');
const writeStream = fs.createWriteStream('downloaded-file.zip');
stream.pipe(writeStream);

// Browser: ReadableStream<Uint8Array>
const streamResponse2 = await pingpong.getStream('https://example.com/large-file.zip');
const reader = streamResponse2.stream.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  
  // Process chunk
  console.log('Received chunk:', value.length, 'bytes');
  // Save to file, process data, etc.
}

// Stream POST request
const streamResponse3 = await pingpong.sendStream({
  method: 'POST',
  url: 'https://api.mockly.codes/upload',
  body: fileData
});
```

### Request Cancellation

```typescript
import pingpong from '@pingpong-js/fetch';

const controller = new AbortController();

// Start request with abort signal
const requestPromise = pingpong.get('https://api.mockly.codes/slow-endpoint', {
  signal: controller.signal
});

// Cancel the request
controller.abort();

// Handle cancellation
try {
  const response = await requestPromise;
} catch (error) {
  // Request was cancelled
  console.log('Request cancelled');
}

// React component example
import { useEffect, useState } from 'react';

function DataComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        const response = await pingpong.get('https://api.mockly.codes/data', {
          signal: controller.signal
        });
        setData(response.json());
      } catch (error) {
        if (!controller.signal.aborted) {
          console.error('Failed to fetch:', error);
        }
      }
    }

    fetchData();

    // Cancel request on unmount
    return () => controller.abort();
  }, []);

  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
```

### Query Parameters

```typescript
import pingpong from '@pingpong-js/fetch';

// Client-level default params using pingpong.create()
const client = pingpong.create({
  baseURL: 'https://api.example.com',
  params: { api_key: 'secret', version: 'v1' }  // Applied to all requests
});

// Request without params uses client defaults
const response1 = await client.get('/users');
// → https://api.example.com/users?api_key=secret&version=v1

// Request-level params are merged with client params
const response2 = await client.get('/users', {
  params: { page: 1, limit: 10 }  // Added to client params
});
// → https://api.example.com/users?api_key=secret&version=v1&page=1&limit=10

// Request params override client params
const response3 = await client.get('/users', {
  params: { version: 'v2', page: 2 }  // version=v2 overrides client's v1
});
// → https://api.example.com/users?api_key=secret&version=v2&page=2

// Array parameters (automatically repeated)
const response4 = await client.get('/products', {
  params: {
    tags: ['new', 'featured', 'bestseller'],
    colors: ['red', 'blue']
  }
});
// → /products?tags=new&tags=featured&tags=bestseller&colors=red&colors=blue

// Simple request params without custom client
const response5 = await pingpong.get('https://api.example.com/users', {
  params: { search: 'john', active: true }
});
// → https://api.example.com/users?search=john&active=true
```

**Benefits:**
- No manual URL construction
- Automatic encoding and escaping
- Clean separation of URL and params
- Request-level params override client defaults
- Array support built-in

### Event Listeners (Interceptors)

```typescript
import pingpong from '@pingpong-js/fetch';

// onRequest - Called before each request (can modify request)
pingpong.onRequest(req => {
  req.headers = { ...req.headers, 'Authorization': `Bearer ${getToken()}` };
  return req;  // Return modified request
});

// onResponse - Called after each response (can modify response)
pingpong.onResponse(res => {
  console.log(`${res.status} - ${res.time}ms`);
  return res;
});

// onError - Called when a request fails
pingpong.onError((error, request) => {
  console.error('Request failed:', request.url, error.message);
  reportToSentry(error);
});

// onRetry - Called before each retry attempt
pingpong.onRetry((attempt, request, response) => {
  console.log(`Retry ${attempt} for ${request.url} (status: ${response.status})`);
});

// onAbort - Called when a request is aborted
pingpong.onAbort(request => {
  console.log('Request aborted:', request.url);
});
```

**Multiple Listeners per Event:**

```typescript
// Add multiple listeners - all will be called in order
const unsub1 = pingpong.onRequest(req => {
  console.log('Logger:', req.method, req.url);
});

const unsub2 = pingpong.onRequest(req => {
  req.headers = { ...req.headers, 'X-Request-ID': uuid() };
  return req;
});

const unsub3 = pingpong.onRequest(req => {
  req.headers = { ...req.headers, 'Authorization': `Bearer ${token}` };
  return req;
});

// Check listener count
console.log(pingpong.listenerCount('request'));  // 3

// Unsubscribe individual listeners
unsub1();  // Remove logger
console.log(pingpong.listenerCount('request'));  // 2

// Clear all listeners of a type
pingpong.off('request');

// Clear ALL listeners
pingpong.off();
```

**Real-World Example: Auth + Logging + Error Handling:**

```typescript
import pingpong from '@pingpong-js/fetch';

const api = pingpong.create({ baseURL: 'https://api.myapp.com' });

// 1. Add auth token
api.onRequest(req => {
  const token = localStorage.getItem('token');
  if (token) {
    req.headers = { ...req.headers, 'Authorization': `Bearer ${token}` };
  }
  return req;
});

// 2. Log all requests
api.onRequest(req => {
  console.log(`→ ${req.method} ${req.url}`);
});

// 3. Log all responses
api.onResponse(res => {
  console.log(`← ${res.status} (${res.time}ms)`);
  return res;
});

// 4. Handle 401 errors globally
api.onResponse(async res => {
  if (res.status === 401) {
    console.log('Token expired, redirecting to login...');
    window.location.href = '/login';
  }
  return res;
});

// 5. Report errors
api.onError((error, req) => {
  analytics.track('api_error', { url: req.url, error: error.message });
});

// Now all requests go through these listeners
const users = await api.get('/users');
```

**Benefits:**
- Multiple listeners per event type
- Listeners can modify request/response
- Easy unsubscribe with returned function
- Clear all with `off()`
- Per-client listeners (don't affect other clients)

### Built-in Plugins

Ready-to-use event listeners for common use cases. Import and plug in!

```typescript
import pingpong, { 
  withAuth,
  withApiKey,
  withBasicAuth,
  withRequestId,
  withHeaders,
  withTimeout,
  withRequestLogging,
  withResponseLogging,
  withRetryLogging,
  withErrorReporting,
  withValidation,
  withSlowRequestWarning,
  withStatusHandlers,
  withThrowOnStatus,
} from '@pingpong-js/fetch';
```

#### Authentication Plugins

```typescript
// Bearer token auth
pingpong.onRequest(withAuth(() => localStorage.getItem('token')));
pingpong.onRequest(withAuth(async () => await getTokenFromStore())); // Async supported

// API key auth
pingpong.onRequest(withApiKey('my-api-key'));
pingpong.onRequest(withApiKey(() => process.env.API_KEY, 'X-Custom-Header'));

// Basic auth
pingpong.onRequest(withBasicAuth('username', 'password'));
```

#### Request Enhancement Plugins

```typescript
// Add unique request ID for tracing
pingpong.onRequest(withRequestId());  // Adds X-Request-ID header
pingpong.onRequest(withRequestId('X-Correlation-ID', () => crypto.randomUUID()));

// Add custom headers to all requests
pingpong.onRequest(withHeaders({ 'X-App-Version': '1.0.0' }));
pingpong.onRequest(withHeaders(() => ({ 'X-Timestamp': Date.now().toString() })));

// Default timeout for all requests (only if not already set)
pingpong.onRequest(withTimeout(10000));  // 10 seconds
```

#### Logging Plugins

```typescript
// Log requests
pingpong.onRequest(withRequestLogging());
pingpong.onRequest(withRequestLogging({ prefix: '🚀', headers: true }));

// Log responses
pingpong.onResponse(withResponseLogging());
pingpong.onResponse(withResponseLogging({ prefix: '📥', body: true }));

// Combined logging (returns [requestListener, responseListener])
const [reqLog, resLog] = withLogging({ prefix: '[API]' });
pingpong.onRequest(reqLog);
pingpong.onResponse(resLog);

// Log retry attempts
pingpong.onRetry(withRetryLogging({ prefix: '🔄' }));
```

#### Response Processing Plugins

```typescript
// Custom validation
pingpong.onResponse(withValidation(data => {
  if (!data.id) throw new Error('Missing id in response');
}));

// Warn on slow requests
pingpong.onResponse(withSlowRequestWarning(3000, (res) => {
  console.warn(`Slow request: ${res.time}ms`);
}));

// Handle specific status codes
pingpong.onResponse(withStatusHandlers({
  401: () => { window.location.href = '/login'; },
  403: () => { showForbiddenMessage(); },
  429: (res) => { console.log('Rate limited!'); },
}));

// Throw on specific status codes
pingpong.onResponse(withThrowOnStatus([401, 403, 500]));
```

#### Error Handling Plugins

```typescript
// Report errors to external service
pingpong.onError(withErrorReporting((error, req) => {
  Sentry.captureException(error, { extra: { url: req.url } });
}));
```

### Case-Insensitive Headers

```typescript
import pingpong from '@pingpong-js/fetch';

const response = await pingpong.get('https://api.example.com/data');

// Access headers with any casing (RFC 2616 compliant)
response.header('content-type');     // 'application/json'
response.header('Content-Type');     // 'application/json'
response.header('CONTENT-TYPE');     // 'application/json'
response.header('CoNtEnT-tYpE');     // 'application/json'

// All return the same value
const contentType = response.header('content-type');
if (contentType?.includes('json')) {
  const data = response.data;  // Already auto-parsed!
}
```

**Benefits:**
- RFC 2616 HTTP spec compliant
- No case-sensitivity bugs
- Works with any casing variation
- Clean, predictable API

### Credentials and Cookie Control (Browser)

```typescript
import pingpong from '@pingpong-js/fetch';

// Control cookie sending behavior using pingpong.create()
const client = pingpong.create({
  credentials: 'include'  // Send cookies with all requests, even cross-origin
});

// Make authenticated requests with cookies
const response = await client.get('https://api.example.com/user/profile');

// Override per-request
const publicData = await client.get('https://api.example.com/public', {
  credentials: 'omit'  // Don't send cookies for this request
});

// Same-origin only (default browser behavior)
const sameOrigin = await client.get('https://api.example.com/data', {
  credentials: 'same-origin'  // Only send cookies for same-origin requests
});
```

**Credentials Options:**
- `'omit'` - Never send cookies
- `'same-origin'` - Send cookies only for same-origin requests (browser default)
- `'include'` - Always send cookies, even for CORS/cross-origin requests

**Benefits:**
- Control cookie behavior explicitly
- Follows Fetch API standard
- Works seamlessly with authentication flows
- Per-request override capability

**Note:** This option only works in browsers. Node.js requests should manage cookies manually using headers.

### Base URL and Headers

```typescript
import pingpong from '@pingpong-js/fetch';

// Create client with base URL and default headers using pingpong.create()
const client = pingpong.create({
  baseURL: 'https://api.mockly.codes',
  headers: {
    'Authorization': 'Bearer token',
    'Content-Type': 'application/json'
  },
  timeout: 5000
});

// Relative URL (uses baseURL)
const users = await client.get('/users');

// Absolute URL (ignores baseURL)
const external = await pingpong.get('https://other-api.com/data');

// Override headers for specific request
const response = await client.get('/users', {
  headers: {
    'Authorization': 'Bearer different-token'
  }
});
```

### Proxy Support (Node.js)

```typescript
import pingpong from '@pingpong-js/fetch';

const client = pingpong.create({
  proxy: 'http://proxy.example.com:8080'
});

// All requests go through proxy
const response = await client.get('https://api.mockly.codes/data');

// Override proxy for specific request
const response2 = await pingpong.get('https://api.mockly.codes/data', {
  proxy: 'http://other-proxy.com:8080'
});
```

### Redirect Handling

```typescript
import pingpong from '@pingpong-js/fetch';

const client = pingpong.create({
  followRedirects: true,   // Default: true
  maxRedirects: 10         // Default: 10
});

// Automatically follows redirects
const response = await client.get('https://api.mockly.codes/redirect');

// Disable redirects for specific request
const response2 = await pingpong.get('https://api.mockly.codes/redirect', {
  followRedirects: false
});

// Limit redirects for specific request
const response3 = await pingpong.get('https://api.mockly.codes/redirect', {
  maxRedirects: 5
});
```

### TypeScript Support

```typescript
import pingpong, { HttpResponse } from '@pingpong-js/fetch';

interface User {
  id: number;
  name: string;
  email: string;
}

interface ApiResponse<T> {
  data: T;
  status: 'success' | 'error';
  message?: string;
}

// Type-safe with generics (v1.4.0) - response.data is typed!
const response = await pingpong.get<User>('https://api.mockly.codes/users/1');
console.log(response.data.name);   // TypeScript knows this is string!
console.log(response.data.email);  // Also typed!

// POST with typed response
const newUser = await pingpong.post<User>('https://api.mockly.codes/users', {
  name: 'John Doe',
  email: 'john@example.com'
});
console.log(newUser.data.id);      // TypeScript knows this is number!

// Complex nested types work too
const apiResponse = await pingpong.get<ApiResponse<User[]>>('https://api.mockly.codes/api/users');
apiResponse.data.data.forEach(user => {
  console.log(user.email);         // Fully typed!
});

// Check response status with type-safe methods
if (response.ok()) {
  console.log('User:', response.data);  // data is typed as User
}

// Legacy .json() method also supports generics
const user = await pingpong.get('https://api.mockly.codes/users/1').then(r => r.json<User>());
console.log(user.name);
```

### Error Handling

```typescript
import pingpong from '@pingpong-js/fetch';

try {
  const response = await pingpong.get('https://api.mockly.codes/data');
  
  // Use chainable methods to check response
  if (response.isError()) {
    console.error('HTTP Error:', response.status, response.statusText);
    const error = response.json();
    console.error('Error details:', error);
  } else if (response.ok()) {
    const data = response.json();
    console.log('Success!', data);
  }
} catch (error) {
  // Network errors, timeouts, etc.
  console.error('Request failed:', error);
}

// Check for cancelled requests
const controller = new AbortController();
const response = await pingpong.get('https://api.mockly.codes/data', {
  signal: controller.signal
});

if (response.status === 0 && response.statusText === 'Cancelled') {
  console.log('Request was cancelled');
}
```

## 🌍 Environment Detection

The library automatically detects the runtime environment and uses the optimal implementation:

- **Node.js**: Uses `undici` for high performance
- **Browser**: Uses native `fetch()` API

### Automatic Detection (Default)

```typescript
import pingpong from '@pingpong-js/fetch';
// Automatically uses the right implementation
```

### Explicit Entry Points

For bundlers like webpack, you can use specific entry points:

**Browser-only** (recommended for browser bundles):
```typescript
import pingpong from '@pingpong-js/fetch/browser';
// Only includes browser code, no Node.js dependencies
```

**Node.js-only**:
```typescript
import pingpong from '@pingpong-js/fetch/node';
// Only includes Node.js code with undici
```

### Bundler Configuration

For browser builds, the library automatically uses the browser entry point via the `browser` field in package.json. Most modern bundlers (Vite, Webpack, Rollup) will automatically pick this up.

**Vite** (automatic, no config needed):
```typescript
import pingpong from '@pingpong-js/fetch';
// Automatically uses browser entry point
```

**Webpack** (optional explicit alias):
```javascript
resolve: {
  alias: {
    '@pingpong-js/fetch': '@pingpong-js/fetch/browser'
  }
}
```

## 📋 Features by Environment

| Feature | Node.js | Browser |
|---------|---------|---------|
| HTTP Methods | ✅ | ✅ |
| Custom Headers | ✅ | ✅ |
| Request Body | ✅ | ✅ |
| Timeout | ✅ | ✅ |
| Redirects | ✅ | ✅ |
| Proxy Support | ✅ | ⚠️ (logs warning) |
| Binary Data | ✅ | ✅ |
| AbortSignal | ✅ | ✅ |
| Streaming | ✅ | ✅ |
| Retry Logic | ✅ | ✅ |
| JSON Parsing | ✅ | ✅ |
| Credentials | ❌ | ✅ |

## 🤝 Contributing

Contributions are welcome! Please read our [Contributing Guide](../../CONTRIBUTING.md) for details.

## 📄 License

MIT © [0xdps](https://github.com/0xdps)

## 🔗 Related Packages

**Core Packages:**
- [`@pingpong-js/cli`](../cli) - Command-line HTTP client
- [`@pingpong-js/core`](../core) - Core utilities and types
- [PingPong Extension](https://github.com/0xdps/pingpong-extension) - Browser extension

**Plugin Packages (Lodash-style):**
Install only what you need:
- [`@pingpong-js/plugin-cache`](../plugin-cache) - Smart caching with memory, localStorage, IndexedDB
- [`@pingpong-js/plugin-retry`](../plugin-retry) - Advanced retry with circuit breaker, rate limiting
- [`@pingpong-js/plugin-zod`](../plugin-zod) - Zod schema validation for type-safe responses

```bash
# Install plugins separately
npm install @pingpong-js/plugin-cache
npm install @pingpong-js/plugin-retry
npm install @pingpong-js/plugin-zod
```

## 💬 Support

For questions, issues, or feature requests, please [open an issue](https://github.com/0xdps/pingpong-extension/issues) or contact [dps.manit@gmail.com](mailto:dps.manit@gmail.com).