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

@igniter-js/caller

v0.1.59

Published

Type-safe HTTP client for Igniter.js with interceptors, retries, caching, and StandardSchema validation

Downloads

287

Readme

@igniter-js/caller

npm version License: MIT TypeScript Node.js Bun

End-to-end type-safe HTTP client
Built on fetch with interceptors, retries, caching, schema validation, and full observability.

Quick StartDocumentationExamplesAPI Reference


✨ Why @igniter-js/caller?

Making API calls shouldn't require choosing between developer experience and runtime safety. Whether you're building a SaaS platform, a mobile backend, or a microservices architecture, you need:

  • End-to-end type safety — Catch API mismatches at build time, not in production
  • Zero configuration — Works anywhere fetch works (Node 18+, Bun, Deno, browsers)
  • Production resilience — Retries, timeouts, fallbacks, and caching built-in
  • Full observability — Telemetry, logging, and global events for every request
  • Schema validation — Runtime type checking with Zod, Valibot, or any StandardSchemaV1 library
  • Developer experience — Fluent API, autocomplete everywhere, zero boilerplate

🚀 Quick Start

Installation

# Using npm
npm install @igniter-js/caller

# Using pnpm
pnpm add @igniter-js/caller

# Using yarn
yarn add @igniter-js/caller

# Using bun
bun add @igniter-js/caller

Optional dependencies:

# For schema validation (any StandardSchemaV1 library)
npm install zod

# For telemetry features
npm install @igniter-js/telemetry

Note: @igniter-js/common is installed automatically. zod is optional for schema validation. Install @igniter-js/telemetry if you plan to use withTelemetry() or @igniter-js/caller/telemetry.

Your First API Call (60 seconds)

import { IgniterCaller } from '@igniter-js/caller';

// 1️⃣ Create the client
const api = IgniterCaller.create()
  .withBaseUrl('https://api.github.com')
  .withHeaders({ 
    'Accept': 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28'
  })
  .build();

// 2️⃣ Make a request
const result = await api.get('/users/octocat').execute();

// 3️⃣ Handle the response
if (result.error) {
  console.error('Request failed:', result.error.message);
} else {
  console.log('User:', result.data);
}

✅ Success! You just made a type-safe HTTP request with zero configuration.


🎯 Core Concepts

Architecture Overview

┌──────────────────────────────────────────────────────────────────┐
│                      Your Application                             │
├──────────────────────────────────────────────────────────────────┤
│  api.get('/users').params({ page: 1 }).execute()                │
└────────────┬─────────────────────────────────────────────────────┘
             │ Type-safe fluent API
             ▼
┌──────────────────────────────────────────────────────────────────┐
│              IgniterCallerBuilder (Immutable)                     │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │ Configuration:                                             │ │
│  │  - baseURL, headers, cookies                               │ │
│  │  - requestInterceptors, responseInterceptors                │ │
│  │  - store, schemas, telemetry, logger                       │ │
│  └────────────────────────────────────────────────────────────┘ │
└────────────┬─────────────────────────────────────────────────────┘
             │ .build()
             ▼
┌──────────────────────────────────────────────────────────────────┐
│              IgniterCallerManager (Runtime)                       │
│  - get/post/put/patch/delete/head() → RequestBuilder           │
│  - request() → axios-style direct execution                    │
│  - Static: batch(), on(), invalidate()                         │
└────────────┬─────────────────────────────────────────────────────┘
             │ Creates
             ▼
┌──────────────────────────────────────────────────────────────────┐
│          IgniterCallerRequestBuilder (Per-Request)                │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │ Configuration:                                             │ │
│  │  - url, method, body, params, headers                      │ │
│  │  - timeout, cache, staleTime, retry                        │ │
│  │  - fallback, responseType (schema or type marker)          │ │
│  └────────────────────────────────────────────────────────────┘ │
└────────────┬─────────────────────────────────────────────────────┘
             │ .execute()
             ▼
┌──────────────────────────────────────────────────────────────────┐
│                 Execution Pipeline                                │
│  1. Cache Check (if staleTime set)                              │
│  2. Request Interceptors                                         │
│  3. Request Validation (if schema defined)                       │
│  4. Fetch with Retry Logic                                       │
│  5. Response Parsing (Content-Type auto-detect)                  │
│  6. Response Validation (if schema defined)                      │
│  7. Response Interceptors                                        │
│  8. Cache Store (if successful)                                  │
│  9. Fallback (if failed and fallback set)                        │
│  10. Telemetry Emission                                         │
│  11. Global Event Emission                                       │
└──────────────────────────────────────────────────────────────────┘

Key Abstractions

  • Builder → Immutable configuration (.withHeaders(), .withSchemas())
  • Manager → Operational HTTP client instance (.get(), .post())
  • RequestBuilder → Per-request fluent API (.body(), .retry(), .execute())
  • Interceptors → Request/Response transformation pipeline
  • Schemas → Type inference + runtime validation (StandardSchemaV1)
  • Cache → In-memory or store-based (Redis, etc.)
  • Events → Global observation for logging/telemetry

📖 Usage Examples

Basic Usage

import { IgniterCaller } from '@igniter-js/caller';

const api = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withHeaders({ Authorization: `Bearer ${process.env.API_TOKEN}` })
  .build();

// GET request
const users = await api.get('/users').execute();

// POST request with body
const newUser = await api
  .post('/users')
  .body({ name: 'John Doe', email: '[email protected]' })
  .execute();

// PUT request with params
const updated = await api
  .put('/users/:id')
  .params({ id: '123' })
  .body({ name: 'Jane Doe' })
  .execute();

// DELETE request
const deleted = await api.delete('/users/123').execute();

// Check for errors
if (users.error) {
  console.error('Request failed:', users.error.message);
  throw users.error;
}

console.log('Users:', users.data);

Query Parameters

// Using .params()
const result = await api
  .get('/search')
  .params({ q: 'typescript', page: 1, limit: 10 })
  .execute();

// GET with body (auto-converted to query params)
const result = await api
  .get('/search')
  .body({ q: 'typescript', page: 1 })
  .execute();
// Becomes: GET /search?q=typescript&page=1

Request Headers

// Per-request headers (merged with defaults)
const result = await api
  .get('/users')
  .headers({ 'X-Custom-Header': 'value' })
  .execute();

// Override default headers
const result = await api
  .get('/public/data')
  .headers({ Authorization: '' }) // Remove auth for this request
  .execute();

Timeout & Retry

// Set timeout
const result = await api
  .get('/slow-endpoint')
  .timeout(5000) // 5 seconds
  .execute();

// Retry with exponential backoff
const result = await api
  .get('/unreliable-endpoint')
  .retry(3, {
    baseDelay: 500,
    backoff: 'exponential',
    retryOnStatus: [408, 429, 500, 502, 503, 504],
  })
  .execute();

Caching

// In-memory cache
const result = await api
  .get('/users')
  .stale(60_000) // Cache for 60 seconds
  .execute();

// Custom cache key
const result = await api
  .get('/users')
  .cache('default', 'custom-cache-key')
  .stale(60_000)
  .execute();

// Store-based caching (Redis, etc.)
const api = IgniterCaller.create()
  .withStore(redisAdapter, {
    ttl: 3600,
    keyPrefix: 'api:',
  })
  .build();

const result = await api
  .get('/users')
  .stale(300_000) // 5 minutes
  .execute();

Fallback Values

// Provide fallback if request fails
const result = await api
  .get('/optional-data')
  .fallback(() => ({ default: 'value' }))
  .execute();

// result.data will be fallback value if request fails
// result.error will still contain the original error

axios-Style Direct Requests

// Using .request() method
const result = await api.request({
  method: 'POST',
  url: '/users',
  body: { name: 'John' },
  headers: { 'X-Custom': 'value' },
  timeout: 5000,
  retry: { maxAttempts: 3, backoff: 'exponential' },
  staleTime: 30_000,
});

Interceptors

const api = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  
  // Request interceptor (modify before sending)
  .withRequestInterceptor(async (request) => {
    return {
      ...request,
      headers: {
        ...request.headers,
        'X-Request-ID': crypto.randomUUID(),
        'X-Timestamp': new Date().toISOString(),
      },
    };
  })
  
  // Response interceptor (transform after receiving)
  .withResponseInterceptor(async (response) => {
    // Normalize empty responses
    if (response.data === '') {
      return { ...response, data: null as any };
    }
    
    // Add custom metadata
    return {
      ...response,
      metadata: {
        cached: response.headers?.get('X-Cache') === 'HIT',
        duration: parseInt(response.headers?.get('X-Duration') || '0'),
      },
    };
  })
  
  .build();

Schema Validation (Type-Safe)

import { IgniterCaller, IgniterCallerSchema } from '@igniter-js/caller';
import { z } from 'zod';

// Define schemas
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

const ErrorSchema = z.object({
  message: z.string(),
  code: z.string(),
});

// Build schema registry
const apiSchemas = IgniterCallerSchema.create()
  .schema('User', UserSchema)
  .schema('Error', ErrorSchema)
  
  .path('/users/:id', (path) =>
    path.get({
      responses: {
        200: path.ref('User').schema,
        404: path.ref('Error').schema,
      },
    })
  )
  
  .path('/users', (path) =>
    path.get({
      responses: {
        200: path.ref('User').array(),
      },
    })
    .post({
      request: z.object({
        name: z.string(),
        email: z.string().email(),
      }),
      responses: {
        201: path.ref('User').schema,
        400: path.ref('Error').schema,
      },
    })
  )
  
  .build();

// Create typed client
const api = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withSchemas(apiSchemas, { mode: 'strict' })
  .build();

// Full type inference!
const result = await api.get('/users/:id')
  .params({ id: '123' }) // ✅ params typed from path pattern
  .execute();

// ✅ result.data is User | undefined (typed from schema)
console.log(result.data?.name);

// POST with typed body
const created = await api.post('/users')
  .body({ name: 'John', email: '[email protected]' }) // ✅ body is typed
  .execute();

// ✅ created.data is User | undefined

Global Events

import { IgniterCallerManager } from '@igniter-js/caller';

// Listen to all requests
const unsubscribe = IgniterCallerManager.on(/.*/, (result, ctx) => {
  console.log(`[${ctx.method}] ${ctx.url}`, {
    status: result.status,
    success: !result.error,
    duration: Date.now() - ctx.timestamp,
  });
});

// Listen to specific paths
IgniterCallerManager.on(/^\/users/, (result, ctx) => {
  if (result.error) {
    console.error('User API failed:', result.error.message);
  }
});

// Listen to exact URL
IgniterCallerManager.on('/auth/login', (result, ctx) => {
  if (!result.error) {
    console.log('User logged in successfully');
  }
});

// Cleanup listener
unsubscribe();

Typed Mocking

import { IgniterCaller, IgniterCallerMock } from '@igniter-js/caller';
import { z } from 'zod';

const schemas = {
  '/users/:id': {
    GET: {
      responses: {
        200: z.object({ id: z.string(), name: z.string() }),
      },
    },
  },
  '/users': {
    POST: {
      request: z.object({ name: z.string() }),
      responses: {
        201: z.object({ id: z.string(), name: z.string() }),
      },
    },
  },
};

// Create mock
const mock = IgniterCallerMock.create()
  .withSchemas(schemas)
  
  // Static response
  .mock('/users/:id', {
    GET: {
      response: { id: 'user_123', name: 'John Doe' },
      status: 200,
    },
  })
  
  // Dynamic response
  .mock('/users', {
    POST: (request) => ({
      response: {
        id: crypto.randomUUID(),
        name: request.body.name,
      },
      status: 201,
      delayMs: 150, // Simulate network delay
    }),
  })
  
  .build();

// Create API with mock
const api = IgniterCaller.create()
  .withSchemas(schemas)
  .withMock({ enabled: true, mock })
  .build();

// All requests use mock
const user = await api.get('/users/:id').params({ id: '123' }).execute();
console.log(user.data); // { id: 'user_123', name: 'John Doe' }

🌍 Real-World Examples

Example 1: E-Commerce Product Catalog

import { IgniterCaller } from '@igniter-js/caller';
import { z } from 'zod';

const ProductSchema = z.object({
  id: z.string(),
  name: z.string(),
  price: z.number(),
  inStock: z.boolean(),
  images: z.array(z.string().url()),
});

const api = IgniterCaller.create()
  .withBaseUrl('https://shop-api.example.com')
  .withHeaders({ 'X-API-Key': process.env.SHOP_API_KEY! })
  .build();

// Fetch products with caching
async function getProducts(category?: string) {
  const result = await api
    .get('/products')
    .params(category ? { category } : {})
    .responseType(z.object({
      products: z.array(ProductSchema),
      total: z.number(),
    }))
    .stale(300_000) // 5 minutes
    .execute();

  if (result.error) {
    throw new Error(`Failed to fetch products: ${result.error.message}`);
  }

  return result.data;
}

// Search with debouncing
let searchAbortController: AbortController | null = null;

async function searchProducts(query: string) {
  // Cancel previous search
  searchAbortController?.abort();
  searchAbortController = new AbortController();

  const result = await api
    .get('/products/search')
    .params({ q: query })
    .timeout(3000)
    .execute();

  return result.data?.products || [];
}

// Usage
const products = await getProducts('electronics');
console.log(`Found ${products.total} products`);

Example 2: Payment Processing with Retries

import { IgniterCaller } from '@igniter-js/caller';

const api = IgniterCaller.create()
  .withBaseUrl('https://payments-api.example.com')
  .withHeaders({
    'X-API-Key': process.env.PAYMENT_API_KEY!,
    'Content-Type': 'application/json',
  })
  .build();

async function processPayment(payment: {
  amount: number;
  currency: string;
  recipient: { accountNumber: string };
}) {
  const result = await api
    .post('/payments')
    .body(payment)
    .timeout(10_000) // 10 seconds
    .retry(3, {
      baseDelay: 500,
      backoff: 'exponential',
      retryOnStatus: [503, 504], // Only retry on server errors
    })
    .fallback(() => ({
      id: 'fallback',
      status: 'pending',
      message: 'Payment queued for retry',
    }))
    .execute();

  if (result.error) {
    console.error('Payment failed:', result.error.message);
    // Log to monitoring service
    throw result.error;
  }

  return result.data;
}

Example 3: Real-Time Analytics Dashboard

import { IgniterCaller, IgniterCallerManager } from '@igniter-js/caller';

const api = IgniterCaller.create()
  .withBaseUrl('https://analytics-api.example.com')
  .build();

// Global event listener for monitoring
IgniterCallerManager.on(/^\/metrics/, (result, ctx) => {
  if (!result.error) {
    console.log(`Metrics fetched in ${Date.now() - ctx.timestamp}ms`);
  }
});

// Polling with cache
async function startMetricsPolling(intervalMs: number) {
  const poll = async () => {
    const result = await api
      .get('/metrics')
      .params({
        start: new Date(Date.now() - 300_000).toISOString(),
        end: new Date().toISOString(),
      })
      .stale(30_000) // 30 seconds
      .execute();

    if (!result.error) {
      updateDashboard(result.data);
    }
  };

  poll(); // Initial fetch
  return setInterval(poll, intervalMs);
}

const pollInterval = await startMetricsPolling(30_000);

Example 4: Multi-Tenant SaaS API Client

import { IgniterCaller } from '@igniter-js/caller';

function createTenantAPI(tenantId: string, apiKey: string) {
  return IgniterCaller.create()
    .withBaseUrl('https://saas-api.example.com')
    .withHeaders({
      'X-Tenant-ID': tenantId,
      'Authorization': `Bearer ${apiKey}`,
    })
    .build();
}

const tenant1API = createTenantAPI('tenant_1', process.env.TENANT_1_KEY!);
const tenant2API = createTenantAPI('tenant_2', process.env.TENANT_2_KEY!);

// Isolated requests per tenant
const tenant1Users = await tenant1API.get('/users').execute();
const tenant2Users = await tenant2API.get('/users').execute();

Example 5: GraphQL-Style Batch Requests

import { IgniterCallerManager } from '@igniter-js/caller';

async function fetchDashboardData() {
  const [users, posts, comments] = await IgniterCallerManager.batch([
    api.get('/users').params({ limit: 10 }).execute(),
    api.get('/posts').params({ limit: 20 }).execute(),
    api.get('/comments').params({ limit: 50 }).execute(),
  ]);

  return {
    users: users.data,
    posts: posts.data,
    comments: comments.data,
  };
}

📚 API Reference

IgniterCaller (Main Builder)

The main entry point for creating an HTTP client.

class IgniterCallerBuilder<TSchemas> {
  static create(): IgniterCallerBuilder<{}>
  
  withBaseUrl(url: string): this
  withHeaders(headers: Record<string, string>): this
  withCookies(cookies: Record<string, string>): this
  withLogger(logger: IgniterLogger): this
  withRequestInterceptor(interceptor: RequestInterceptor): this
  withResponseInterceptor(interceptor: ResponseInterceptor): this
  withStore(store: StoreAdapter, options?: StoreOptions): this
  withSchemas<T>(schemas: T, validation?: ValidationOptions): Builder<T>
  withTelemetry(telemetry: TelemetryManager): this
  withMock(config: MockConfig): this
  
  build(): IgniterCallerManager<TSchemas>
}

Methods:

| Method | Parameters | Returns | Description | |--------|------------|---------|-------------| | create() | None | Builder<{}> | Static factory for new builder | | withBaseUrl() | url: string | this | Set base URL prefix for all requests | | withHeaders() | headers: Record<string, string> | this | Merge default headers into every request | | withCookies() | cookies: Record<string, string> | this | Set default cookies (sent as Cookie header) | | withLogger() | logger: IgniterLogger | this | Attach logger for request lifecycle logging | | withRequestInterceptor() | interceptor: Function | this | Add request modifier (runs before fetch) | | withResponseInterceptor() | interceptor: Function | this | Add response transformer (runs after fetch) | | withStore() | store: Adapter, options? | this | Configure persistent cache (Redis, etc.) | | withSchemas() | schemas: Map, validation? | Builder<T> | Enable type inference + validation | | withTelemetry() | telemetry: Manager | this | Connect to telemetry system | | withMock() | config: MockConfig | this | Enable mock mode for testing | | build() | None | Manager | Build the operational client instance |

Example:

const api = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withHeaders({ Authorization: 'Bearer token' })
  .withStore(redisAdapter)
  .withSchemas(schemas)
  .build();

IgniterCallerManager (HTTP Client)

The operational HTTP client instance for making requests.

class IgniterCallerManager<TSchemas> {
  // HTTP Methods
  get<T>(url?: string): RequestBuilder<T>
  post<T>(url?: string): RequestBuilder<T>
  put<T>(url?: string): RequestBuilder<T>
  patch<T>(url?: string): RequestBuilder<T>
  delete<T>(url?: string): RequestBuilder<T>
  head<T>(url?: string): RequestBuilder<T>
  
  // Direct execution (axios-style)
  request<T>(options: DirectRequestOptions): Promise<ApiResponse<T>>
  
  // Static methods
  static on(pattern: string | RegExp, callback: EventCallback): () => void
  static off(pattern: string | RegExp, callback?: EventCallback): void
  static invalidate(key: string): Promise<void>
  static invalidatePattern(pattern: string): Promise<void>
  static batch<T extends Promise<any>[]>(requests: T): Promise<AwaitedArray<T>>
}

Methods:

| Method | Arguments | Returns | Description | |--------|-----------|---------|-------------| | get() | url?: string | RequestBuilder | Create GET request | | post() | url?: string | RequestBuilder | Create POST request | | put() | url?: string | RequestBuilder | Create PUT request | | patch() | url?: string | RequestBuilder | Create PATCH request | | delete() | url?: string | RequestBuilder | Create DELETE request | | head() | url?: string | RequestBuilder | Create HEAD request | | request() | options: DirectRequestOptions | Promise<ApiResponse> | Execute request directly | | on() | pattern, callback | unsubscribe: Function | Register global event listener | | off() | pattern, callback? | void | Remove event listener(s) | | invalidate() | key: string | Promise<void> | Invalidate specific cache entry | | invalidatePattern() | pattern: string | Promise<void> | Invalidate cache by pattern | | batch() | requests: Promise[] | Promise<Results[]> | Execute requests in parallel |


RequestBuilder (Fluent Request API)

Per-request configuration builder.

class IgniterCallerRequestBuilder<TResponse> {
  withLogger(logger: IgniterLogger): this
  url(url: string): this
  body<T>(body: T): this
  params<T>(params: T): this
  headers(headers: Record<string, string>): this
  timeout(ms: number): this
  cache(cache: RequestCache, key?: string): this
  stale(ms: number): this
  retry(attempts: number, options?: RetryOptions): this
  fallback<T>(fn: () => T): this
  responseType<T>(schema?: StandardSchemaV1<T>): RequestBuilder<T>
  getFile(url: string): { execute(): Promise<IgniterCallerFileResponse> }
  
  execute(): Promise<ApiResponse<TResponse>>
}

Methods:

| Method | Parameters | Returns | Description | |--------|------------|---------|-------------| | withLogger() | logger: IgniterLogger | this | Override logger for this request | | url() | url: string | this | Set request URL | | body() | body: any | this | Set request body (JSON, FormData, Blob) | | params() | params: Record<string, string \| number \| boolean> | this | Set query parameters | | headers() | headers: Record<string, string> | this | Merge additional headers | | timeout() | ms: number | this | Set request timeout | | cache() | cache: RequestCache, key?: string | this | Set fetch cache mode + optional cache key | | stale() | ms: number | this | Set cache stale time | | retry() | attempts: number, options? | this | Configure retry behavior | | fallback() | fn: () => T | this | Provide fallback value on error | | responseType() | schema?: StandardSchemaV1 | RequestBuilder<T> | Set expected response type (validates JSON/XML/CSV) | | getFile() | url: string | { execute(): Promise<IgniterCallerFileResponse> } | Deprecated. Use responseType<File>().execute() instead | | execute() | None | Promise<ApiResponse> | Execute the request |


Types

ApiResponse

interface IgniterCallerApiResponse<TData> {
  data?: TData;
  error?: IgniterCallerError;
  status?: number;
  headers?: Headers;
}

RetryOptions

interface IgniterCallerRetryOptions {
  maxAttempts: number;
  baseDelay?: number;
  backoff?: 'linear' | 'exponential';
  retryOnStatus?: number[];
}

ValidationOptions

interface IgniterCallerSchemaValidationOptions {
  mode?: 'strict' | 'soft' | 'off';
  onValidationError?: (error: ValidationError) => void;
}

🔧 Configuration

Store Adapter

Configure persistent caching with Redis or other stores:

interface IgniterCallerStoreAdapter<TClient = any> {
  readonly client: TClient;
  get<T = any>(key: string): Promise<T | null>;
  set(key: string, value: any, options?: { ttl?: number; [key: string]: any }): Promise<void>;
  delete(key: string): Promise<void>;
  has(key: string): Promise<boolean>;
}

interface IgniterCallerStoreOptions {
  ttl?: number;
  keyPrefix?: string;
  fallbackToFetch?: boolean;
}

Example:

import { IgniterCaller } from '@igniter-js/caller';

const redisAdapter: IgniterCallerStoreAdapter = {
  client: redis,
  async get(key) { return await redis.get(key); },
  async set(key, value, options) {
    const ttl = options?.ttl ?? 3600;
    await redis.setex(key, ttl, value);
  },
  async delete(key) { await redis.del(key); },
  async has(key) { return (await redis.exists(key)) === 1; },
};

const api = IgniterCaller.create()
  .withStore(redisAdapter, {
    ttl: 3600,
    keyPrefix: 'api:',
  })
  .build();

Schema Validation

Enable runtime validation with any StandardSchemaV1 library:

import { z } from 'zod';

const api = IgniterCaller.create()
  .withSchemas(schemas, {
    mode: 'strict', // 'strict' | 'soft' | 'off'
    onValidationError: (error) => {
      console.error('Validation failed:', error);
    },
  })
  .build();

Modes:

  • strict: Throw on validation failure (default)
  • soft: Log error and continue
  • off: Skip validation

Telemetry Integration

Enable structured observability for every request in your application:

import { IgniterCaller } from '@igniter-js/caller';
import { IgniterTelemetry } from '@igniter-js/telemetry';
import { IgniterCallerTelemetryEvents } from '@igniter-js/caller/telemetry';

// 1. Create telemetry manager with caller events
const telemetry = IgniterTelemetry.create()
  .withService('my-api-gateway')
  .addEvents(IgniterCallerTelemetryEvents)
  .build();

// 2. Wire telemetry into your caller instance
const api = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withTelemetry(telemetry)
  .build();

// All requests now emit structured events automatically:
// - igniter.caller.request.execute.started
// - igniter.caller.request.execute.success
// - igniter.caller.request.execute.error
// - igniter.caller.request.timeout.error
// - igniter.caller.cache.read.hit
// - igniter.caller.retry.attempt.started
// - igniter.caller.validation.request.error
// - igniter.caller.validation.response.error

Telemetry Events Reference:

| Event | Attributes | When Emitted | |-------|-----------|--------------| | request.execute.started | method, url, baseUrl, timeoutMs | Before each request | | request.execute.success | method, url, durationMs, status, contentType, cache.hit, fallback | Request succeeds | | request.execute.error | method, url, durationMs, status, error.code, error.message | Request fails | | request.timeout.error | method, url, timeoutMs | Request times out | | cache.read.hit | method, url, cache.key, cache.staleTime | Cache hit occurs | | retry.attempt.started | method, url, retry.attempt, retry.maxAttempts, retry.delayMs | Each retry attempt | | validation.request.error | method, url, validation.type, validation.error | Request validation fails | | validation.response.error | method, url, validation.type, validation.error, status | Response validation fails |


🧪 Testing

Unit Testing with Mock Adapter

import { describe, it, expect } from 'vitest';
import { IgniterCaller, IgniterCallerMock } from '@igniter-js/caller';
import { MockCallerStoreAdapter } from '@igniter-js/caller/adapters';

describe('API Client', () => {
  const mock = IgniterCallerMock.create()
    .mock('/users/:id', {
      GET: (request) => ({
        response: { id: request.params.id, name: 'Test User' },
        status: 200,
      }),
    })
    .build();

  const api = IgniterCaller.create()
    .withMock({ enabled: true, mock })
    .build();

  it('should fetch user', async () => {
    const result = await api.get('/users/:id').params({ id: '123' }).execute();
    
    expect(result.error).toBeUndefined();
    expect(result.data).toEqual({ id: '123', name: 'Test User' });
  });

  it('should handle errors', async () => {
    const mock = IgniterCallerMock.create()
      .mock('/error', {
        GET: { response: null, status: 500 },
      })
      .build();

    const api = IgniterCaller.create()
      .withMock({ enabled: true, mock })
      .build();

    const result = await api.get('/error').execute();
    
    expect(result.error).toBeDefined();
  });
});

Integration Testing

import { IgniterCaller } from '@igniter-js/caller';

describe('Integration: Real API', () => {
  const api = IgniterCaller.create()
    .withBaseUrl(process.env.TEST_API_URL!)
    .build();

  it('should fetch users from real API', async () => {
    const result = await api.get('/users').execute();
    
    expect(result.error).toBeUndefined();
    expect(Array.isArray(result.data)).toBe(true);
  });
});

🎨 Best Practices

✅ Do

// ✅ Use immutable builders
const api = IgniterCaller.create()
  .withBaseUrl('...')
  .withHeaders({ ... })
  .build();

// ✅ Handle errors explicitly
const result = await api.get('/users').execute();
if (result.error) {
  console.error(result.error);
  throw result.error;
}

// ✅ Use schema validation for type safety
const api = IgniterCaller.create()
  .withSchemas(schemas, { mode: 'strict' })
  .build();

// ✅ Cache expensive requests
const result = await api
  .get('/expensive')
  .stale(300_000) // 5 minutes
  .execute();

// ✅ Use retry for transient failures
const result = await api
  .get('/unreliable')
  .retry(3, { backoff: 'exponential' })
  .execute();

// ✅ Provide fallbacks for optional data
const result = await api
  .get('/optional')
  .fallback(() => defaultValue)
  .execute();

❌ Don't

// ❌ Don't mutate builder state
const builder = IgniterCaller.create();
builder.state.baseURL = 'https://api.example.com'; // ❌ Won't work

// ❌ Don't ignore errors
const result = await api.get('/users').execute();
console.log(result.data); // ❌ Might be undefined

// ❌ Don't skip validation in production
const api = IgniterCaller.create()
  .withSchemas(schemas, { mode: 'off' }) // ❌ Risky
  .build();

// ❌ Don't cache mutations
const result = await api
  .post('/users')
  .stale(60_000) // ❌ Don't cache POST/PUT/PATCH/DELETE
  .execute();

// ❌ Don't retry non-idempotent operations
const result = await api
  .post('/payments')
  .retry(3) // ❌ Might duplicate payment
  .execute();

🚨 Troubleshooting

Error: Request timeout

Cause: Request took longer than configured timeout

Solution:

// Increase timeout
const result = await api
  .get('/slow-endpoint')
  .timeout(30_000) // 30 seconds
  .execute();

Error: Validation failed

Cause: Response doesn't match schema

Solution:

// Check schema definition
const UserSchema = z.object({
  id: z.string(),
  name: z.string(), // ❌ API returns `username`
});

// Fix schema
const UserSchema = z.object({
  id: z.string(),
  username: z.string(), // ✅ Matches API
});

// Or use soft mode
const api = IgniterCaller.create()
  .withSchemas(schemas, { mode: 'soft' })
  .build();

Error: Cache not invalidating

Cause: Cache key doesn't match

Solution:

// Ensure consistent cache keys
const result1 = await api.get('/users').cache('default', 'users-list').execute();

// Later, invalidate with same key
await IgniterCallerManager.invalidate('users-list');

Performance: Slow requests

Diagnosis: No caching or retries

Solution:

// Enable caching for read-heavy endpoints
const result = await api
  .get('/heavy-computation')
  .stale(600_000) // 10 minutes
  .execute();

// Use store-based cache for persistence
const api = IgniterCaller.create()
  .withStore(redisAdapter)
  .build();

Type Inference: Not working

Cause: Schema path doesn't match request URL

Solution:

// ❌ Schema path doesn't match
const schemas = {
  '/users': { GET: { responses: { 200: UserSchema } } }
};

const result = await api.get('/users/list').execute(); // ❌ No match

// ✅ Fix schema or URL
const schemas = {
  '/users/list': { GET: { responses: { 200: UserSchema } } }
};

const result = await api.get('/users/list').execute(); // ✅ Typed

Error: Mock not resolving

Cause: Mock path doesn't match the requested URL pattern.

Solution:

// ❌ Mock uses '/users/1' but request uses '/users/:id'
const mock = IgniterCallerMock.create()
  .mock('/users/1', { GET: { response: {...}, status: 200 } })
  .build();

// ✅ Use parameterized paths that match schema definitions
const mock = IgniterCallerMock.create()
  .withSchemas(schemas)
  .mock('/users/:id', {
    GET: (request) => ({
      response: { id: request.params.id, name: 'Test' },
      status: 200,
    }),
  })
  .build();

Error: Interceptor not firing

Cause: Interceptor threw an error and subsequent interceptors were skipped.

Solution:

// ✅ Always wrap interceptors in try-catch for resilience
api.withRequestInterceptor(async (request) => {
  try {
    const token = await getAuthToken();
    return {
      ...request,
      headers: { ...request.headers, Authorization: `Bearer ${token}` },
    };
  } catch (error) {
    console.error('Failed to attach auth token:', error);
    return request; // Continue without the header
  }
});

Error: File is not available in this runtime

Cause: Using .getFile() or .responseType<File>() in environments without the File constructor (Node.js < 20, some serverless runtimes).

Solution:

// ✅ Use Blob instead of File
const result = await api
  .get('/download')
  .responseType<Blob>()
  .execute();

if (result.data) {
  const buffer = Buffer.from(await result.data.arrayBuffer());
  await fs.writeFile('/tmp/output.pdf', buffer);
}

// Or check for File availability
const response = await api.get('/download').execute();
const blob = await fetch(response.request?.url).then(r => r.blob());

🔗 Framework Integration

Next.js (App Router)

// lib/api.ts
import { IgniterCaller } from '@igniter-js/caller';

export const api = IgniterCaller.create()
  .withBaseUrl(process.env.NEXT_PUBLIC_API_URL!)
  .build();

// app/users/page.tsx
import { api } from '@/lib/api';

export default async function UsersPage() {
  const result = await api.get('/users').execute();
  
  if (result.error) {
    throw new Error('Failed to fetch users');
  }
  
  return (
    <div>
      {result.data.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

React Client Integration (@igniter-js/caller/client)

The package ships a dedicated React layer with an IgniterCallerProvider, useQuery/useMutate hooks, cache management, and typed client wrappers — similar to TanStack Query but fully integrated with the caller's type system.

// providers/api.tsx
'use client';

import { IgniterCallerProvider } from '@igniter-js/caller/client';
import { api, authApi } from '@/lib/api';

export function APIProvider({ children }: { children: React.ReactNode }) {
  return (
    <IgniterCallerProvider
      callers={{ api, authApi }}
      configs={{
        api: {
          headers: { 'X-Client': 'web' },
          query: { version: 'v1' },
        },
        authApi: {
          headers: { 'X-Auth-Client': 'web' },
          cookies: { session: document.cookie },
        },
      }}
      onError={(error) => {
        console.error('API Error:', error.message);
      }}
    >
      {children}
    </IgniterCallerProvider>
  );
}
// hooks/use-users.ts
import { useIgniterCaller } from '@igniter-js/caller/client';

export function useUsers() {
  const caller = useIgniterCaller();

  // useQuery on GET — auto-fetches on mount, supports staleTime, refetchInterval
  const usersQuery = caller('api').get('/users').useQuery({
    staleTime: 30_000,
    refetchOnWindowFocus: true,
  });

  // useMutate on POST — manual trigger with typed variables
  const createUser = caller('api').post('/users').useMutate({
    onSuccess: () => usersQuery.invalidate(),
  });

  const createUserMutation = caller('api').post('/users').useMutate({
    onSuccess: (data) => console.log('Created:', data),
    onError: (error) => console.error('Failed:', error.message),
    onSettled: () => console.log('Mutation settled'),
  });

  return {
    data: usersQuery.data,
    isLoading: usersQuery.isLoading,
    isFetching: usersQuery.isFetching,
    isError: usersQuery.isError,
    error: usersQuery.error,
    refetch: usersQuery.refetch,
    invalidate: usersQuery.invalidate,
    createUser: createUserMutation.mutate,
    isCreating: createUserMutation.isLoading,
  };
}
// app/users/page.tsx
'use client';

import { APIProvider } from '@/providers/api';
import { useUsers } from '@/hooks/use-users';

function UsersContent() {
  const { data, isLoading, createUser, isCreating, invalidate } = useUsers();

  if (isLoading) return <div>Loading users...</div>;

  return (
    <div>
      <button
        onClick={() => createUser({ body: { name: 'New User', email: '[email protected]' } })}
        disabled={isCreating}
      >
        Add User
      </button>
      <button onClick={() => invalidate()}>Refresh</button>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default function UsersPage() {
  return (
    <APIProvider>
      <UsersContent />
    </APIProvider>
  );
}

Client Hook API:

| Hook | Method | Description | |------|--------|-------------| | .useQuery(options?) | GET, HEAD | Auto-fetch on mount, supports staleTime, refetchInterval, refetchOnWindowFocus, enabled, initialData | | .useMutate(options?) | POST, PUT, PATCH, DELETE | Manual trigger via .mutate(variables?), supports onSuccess/onError/onSettled/onLoading |

Client Config API (caller('key').config):

| Config Option | Type | Description | |--------------|------|-------------| | headers | Record<string, string> | Default headers for this caller | | cookies | Record<string, string> | Default cookies sent as Cookie header | | query | Record<string, string \| number \| boolean> | Default query params appended to every request | | onQueryLoading | (loading: boolean) => void | Global loading callback for queries | | onQuerySuccess | (data: unknown) => void | Global success callback for queries | | onQueryError | (error: IgniterError) => void | Global error callback for queries | | onQuerySettled | (data: unknown, error: unknown) => void | Global settled callback for queries |

React with TanStack Query

import { useQuery } from '@tanstack/react-query';
import { api } from './api';

function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const result = await api.get('/users').execute();
      if (result.error) throw result.error;
      return result.data;
    },
  });
}

function Users() {
  const { data, isLoading, error } = useUsers();
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return <ul>{data.map(...)}</ul>;
}

Express.js

import express from 'express';
import { IgniterCaller } from '@igniter-js/caller';

const app = express();
const api = IgniterCaller.create()
  .withBaseUrl('https://external-api.example.com')
  .build();

app.get('/proxy/users', async (req, res) => {
  const result = await api.get('/users').execute();
  
  if (result.error) {
    return res.status(result.status || 500).json({
      error: result.error.message,
    });
  }
  
  res.json(result.data);
});

📊 Performance Tips

  1. Use caching aggressively for read-heavy endpoints
  2. Enable store-based caching (Redis) for distributed systems
  3. Batch parallel requests with IgniterCallerManager.batch()
  4. Set appropriate timeouts to fail fast
  5. Use retry with exponential backoff for transient failures
  6. Minimize interceptor overhead (avoid heavy computation)
  7. Enable compression via headers (Accept-Encoding: gzip)

🤝 Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

Development Setup

git clone https://github.com/felipebarcelospro/igniter-js.git
cd igniter-js/packages/caller
npm install
npm run build
npm test

📄 License

MIT © Felipe Barcelos


🔗 Related Packages


💬 Community & Support


Built with ❤️ by the Igniter.js team