npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pingpong-js/fetch

v1.0.2

Published

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

Readme

🏓 @pingpong-js/fetch

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

npm version License: MIT

A lightweight, universal HTTP client that automatically detects your environment and uses the optimal implementation:

  • Node.js: Uses 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

npm install @pingpong-js/fetch

🔌 Optional Plugins (Lodash-style)

Install only the plugins you need:

# 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
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

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: '[email protected]'
});
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

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

📖 Examples

Check out the examples directory for complete working examples of all features:

Node.js Examples

Browser Examples

See examples/README.md for complete example documentation.

📖 API Reference

pingpong

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

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:

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:

(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.

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.

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.

// 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:

// 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.

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).

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

Check if status is 4xx or 5xx.

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

Check if response was a redirect (3xx).

const response = await pingpong.get('/redirect');
console.log(response.redirected()); // true
json<T>(): T

Parse response body as JSON.

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

Get response body as text.

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

Get the raw response object (without methods).

const response = await pingpong.get('/data');
const raw = response.raw();
header(name: string): string | undefined

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

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

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

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

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:

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

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

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: '[email protected]'
});
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

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

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

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

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)

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:

// 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:

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!

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

Authentication Plugins

// 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

// 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

// 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

// 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

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

Case-Insensitive Headers

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)

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

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)

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

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

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: '[email protected]'
});
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

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)

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):

import pingpong from '@pingpong-js/fetch/browser';
// Only includes browser code, no Node.js dependencies

Node.js-only:

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):

import pingpong from '@pingpong-js/fetch';
// Automatically uses browser entry point

Webpack (optional explicit alias):

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 for details.

📄 License

MIT © 0xdps

🔗 Related Packages

Core Packages:

Plugin Packages (Lodash-style): Install only what you need:

# 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 or contact [email protected].