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

Published

Type-safe HTTP client for Igniter.js with interceptors, retries, caching, StandardSchema validation, response adapters, and a React client (@igniter-js/caller/client)

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
  • Response adapters — Unwrap Igniter.js { data, error } wire envelopes into clean result.ok / result.data DX
  • React client — Reduce API, typed session/request, onEvent, cache, suspense (@igniter-js/caller/client)
  • Developer experience — Fluent API, autocomplete everywhere, zero boilerplate

📦 What's new in 0.2.0

| Area | Highlights | |------|------------| | React client | Single-caller Provider, reduce API (tags + operationId), session / request / reset({ scope }) / useSession, onEvent + shortcuts, cache/suspense/optimistic mutations | | Adapters | .withAdapter() + IgniterCallerCoreAdapter for Igniter.js envelope unwrap (same typing in execute() and React hooks via getAdapter() / InferCallerAdapter) | | Schemas | Endpoint body / query / headers / meta, .query(), typed errors, OPTIONS, FormData for z.file() | | Tests | npm test = Vitest + tsc --noEmit (type suites are gated) |

Breaking (React client): multi-caller callers map removed; Provider config/contextsession; client invalidateAll/clear/refreshContextreset / session / request; useMutateuseMutation; useIgniterCaller<TCaller, TSession>(). See CHANGELOG.


🚀 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. Install react (>=18) when using @igniter-js/caller/client.

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.ok) {
  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').query({ page: 1 }).execute()                 │
└────────────┬─────────────────────────────────────────────────────┘
             │ Type-safe fluent API
             ▼
┌──────────────────────────────────────────────────────────────────┐
│              IgniterCallerBuilder (Immutable)                     │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │ Configuration:                                             │ │
│  │  - baseURL, headers, cookies                               │ │
│  │  - requestInterceptors, responseInterceptors                │ │
│  │  - store, schemas, adapter, telemetry, logger              │ │
│  └────────────────────────────────────────────────────────────┘ │
└────────────┬─────────────────────────────────────────────────────┘
             │ .build()
             ▼
┌──────────────────────────────────────────────────────────────────┐
│              IgniterCallerManager (Runtime)                       │
│  - get/post/put/patch/delete/head/options() → 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 Adapter (adaptSuccess / adaptError)                 │
│  8. Response Interceptors                                        │
│  9. Cache Store (if successful)                                  │
│  10. Fallback (if failed and fallback set)                        │
│  11. Telemetry Emission                                          │
│  12. Global Event Emission                                       │
└──────────────────────────────────────────────────────────────────┘

Key Abstractions

  • Builder → Immutable configuration (.withHeaders(), .withSchemas(), .withAdapter())
  • Manager → Operational HTTP client instance (.get(), .post())
  • RequestBuilder → Per-request fluent API (.body(), .query(), .retry(), .execute())
  • Response Adapter → Remaps validated wire bodies into execute() DX (unwrap envelopes, flatten error codes)
  • 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.ok) {
  console.error('Request failed:', users.error.message);
  throw users.error;
}

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

Query Parameters

// Path params — replace `:id` in the URL template
const user = await api
  .get('/users/:id')
  .params({ id: '123' })
  .execute();

// Query params — append `?page=1&limit=10` (typed when schema defines `query`)
const result = await api
  .get('/search')
  .query({ q: 'typescript', page: 1, limit: 10 })
  .execute();

// GET with body (auto-converted to query params — legacy convenience)
const legacy = 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.ok will be false and result.error contains 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({
      query: z.object({
        page: z.number().optional(),
        limit: z.number().optional(),
      }),
      responses: {
        200: path.ref('User').array(),
      },
    })
    .post({
      body: 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' }) // ✅ path params from `:id`
  .execute();

if (result.ok) {
  // ✅ result.data is User (success only)
  console.log(result.data.name);
} else {
  // ✅ result.error is typed; narrow with error.code when schemas use literal codes
  console.error(result.error.message);
}

// GET with typed query
const listed = await api.get('/users')
  .query({ page: 1, limit: 10 })
  .execute();

// POST with typed body (`request` remains a legacy alias of `body`)
const created = await api.post('/users')
  .body({ name: 'John', email: '[email protected]' })
  .execute();

Igniter.js Integration (Response Adapter)

Igniter.js APIs return { data, error } envelopes on the wire. Use IgniterCallerCoreAdapter to unwrap success payloads and surface domain error codes:

import { IgniterCaller } from '@igniter-js/caller';
import { IgniterCallerCoreAdapter } from '@igniter-js/caller/adapters';
import { apiCallerSchemas } from './callers/api/schema';

const api = IgniterCaller.create()
  .withBaseUrl(process.env.API_URL!)
  .withSchemas(apiCallerSchemas)
  .withAdapter(IgniterCallerCoreAdapter.create())
  .build();

const result = await api.get('/orders/:id').params({ id: '12' }).execute();

if (result.ok) {
  console.log(result.data); // Order — unwrapped from wire envelope
} else if (result.error.code === 'ORDER_NOT_FOUND') {
  console.error(result.error.details); // { orderId: string } — required when schema defines concrete details
}

Generate schemas with igniter generate caller. The CLI emits OpenAPI-aligned wire schemas; pair them with IgniterCallerCoreAdapter for ideal DX.

| Configuration | Success result.data | Error handling | | ------------- | --------------------- | -------------- | | No adapter (identity default) | Full wire body { data, error: null } | Nested in wire envelope | | IgniterCallerCoreAdapter | Unwrapped domain payload | Flat result.error.code with typed details after narrowing |

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.ok,
    duration: Date.now() - ctx.timestamp,
  });
});

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

// Listen to exact URL
IgniterCallerManager.on('/auth/login', (result, ctx) => {
  if (result.ok) {
    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.ok) {
    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')
    .query({ q: query })
    .timeout(3000)
    .execute();

  return result.ok ? 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.ok) {
    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.ok) {
    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.ok) {
      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>
  withAdapter(adapter: IgniterCallerResponseAdapter): this
  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 | | withAdapter() | adapter: IgniterCallerResponseAdapter | this | Remap wire bodies (e.g. unwrap Igniter.js envelopes) | | 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>
  options<T>(url?: string): RequestBuilder<T>
  
  // Direct execution (axios-style)
  request<T>(options: DirectRequestOptions): Promise<IgniterCallerApiResponse<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 | | options() | url?: string | RequestBuilder | Create OPTIONS request | | request() | options: DirectRequestOptions | Promise<IgniterCallerApiResponse> | 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
  query<T>(query: 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<IgniterCallerApiResponse<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 path template params (:id) | | query() | query: Record<string, string \| number \| boolean> | this | Set query string params (typed when schema defines query) | | 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<IgniterCallerApiResponse> | Execute the request |


Types

IgniterCallerApiResponse

execute() returns a discriminated union — always branch on result.ok:

type IgniterCallerApiResponse<TData, TError = IgniterError> =
  | { ok: true; data: TData; status: number; headers?: Headers }
  | { ok: false; error: TError; status: number; headers?: Headers };

After narrowing if (!result.ok && result.error.code === 'ORDER_NOT_FOUND'), result.error.details is required when the schema defines concrete error details.

RetryOptions

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

ValidationOptions

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

🔧 Configuration

Response Adapters

Response adapters remap validated wire bodies into the execute() developer experience. Exported from @igniter-js/caller/adapters:

import {
  IgniterCallerIdentityAdapter,  // default — wire passthrough
  IgniterCallerCoreAdapter,      // unwrap Igniter.js { data, error } envelopes
  MockCallerStoreAdapter,          // in-memory store for cache tests
} from '@igniter-js/caller/adapters';

| Adapter | kind | Use when | | ------- | ------ | -------- | | IgniterCallerIdentityAdapter | 'identity' | Generic REST APIs (default) | | IgniterCallerCoreAdapter | 'igniter-core' | Igniter.js APIs with { data, error } wire envelopes |

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.ok).toBe(true);
    if (result.ok) {
      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.ok).toBe(false);
    if (!result.ok) {
      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.ok).toBe(true);
    if (result.ok) {
      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.ok) {
  console.error(result.error);
  throw result.error;
}

// ✅ Use IgniterCallerCoreAdapter for Igniter.js APIs
import { IgniterCallerCoreAdapter } from '@igniter-js/caller/adapters';

const igniterApi = IgniterCaller.create()
  .withSchemas(apiCallerSchemas)
  .withAdapter(IgniterCallerCoreAdapter.create())
  .build();

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

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

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

// ✅ Provide fallbacks for optional data
const withFallback = await typedApi
  .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();
if (!result.ok) throw result.error;
console.log(result.data); // ✅ Safe after ok check

// ❌ 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.ok) {
    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 React client is a single-caller layer with:

  • Reduce API from schema tags + operationId (api.users.list.useQuery())
  • Path fallback (api.get('/x').useQuery())
  • Typed session (resolve + strategy + optional Standard Schema)
  • Runtime request overlay (api.request.set)
  • Discriminated onEvent lifecycle (plus ergonomic shortcuts)
  • Cache with dedupe, GC, abort, structural sharing, and optional multi-tab persistence
// lib/caller.ts
import { z } from 'zod';
import { IgniterCaller } from '@igniter-js/caller';
import { useIgniterCaller } from '@igniter-js/caller/client';

export const AppSessionSchema = z.object({
  id: z.string(),
  name: z.string(),
});
export type AppSession = z.infer<typeof AppSessionSchema>;
export type AppCaller = typeof apiManager;
export const useAppCaller = () => useIgniterCaller<AppCaller, AppSession>();

export const apiManager = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withSchemas(/* … tags + operationId … */)
  .build();
// providers/api.tsx
'use client';

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

export function APIProvider({ children }: { children: React.ReactNode }) {
  return (
    <IgniterCallerProvider
      caller={apiManager}
      session={{
        strategy: 'ttl',
        ttlMs: 30_000,
        schema: AppSessionSchema,
        resolve: async () => ({
          headers: { Authorization: `Bearer ${await getToken()}` },
          credentials: 'include',
          data: await fetchSession(),
        }),
      }}
      persistence={{ enabled: true, storage: 'localStorage' }}
      onEvent={(e) => console.debug(e)}
      onQueryError={(e) => console.error(e.error)}
    >
      {children}
    </IgniterCallerProvider>
  );
}
const api = useAppCaller();

const { data: session, refresh, clear } = api.useSession();

const list = api.users.list.useQuery({
  query: { page: '1' },
  staleTime: 30_000,
  keepPreviousData: true,
  select: (data) => data.length,
});

const create = api.users.create.useMutation({
  onSuccess: () => api.users.list.invalidate(),
});

await api.users.list.query({ query: { page: '1' } });
api.users.list.prefetch({ query: { page: '1' } });

// Path fallback
api.get('/legacy').useQuery();
api.post('/legacy').useMutation();

// Session / request / reset
api.session.set({ headers: { Authorization: `Bearer ${token}` } });
api.request.set({ headers: { 'X-Locale': 'pt-BR' } });
api.reset({ scope: 'cache' });   // invalidate all queries
api.reset({ scope: 'session' }); // clear session + re-resolve next call
api.reset({ scope: 'request' }); // clear request overlay
api.reset({ scope: 'all' });     // full client reset

Client Hook API:

| API | Surface | Description | |-----|---------|-------------| | .useQuery(options?) | Query ops / GET/HEAD/OPTIONS | Auto-fetch, staleTime, gcTime, select, keepPreviousData, placeholderData, focus/interval refetch | | .useSuspenseQuery(options?) | Query ops | Suspense-compatible query | | .useMutation(options?) | Mutation ops / POST/PUT/PATCH/DELETE | Manual mutate/mutateAsync, optimistic onMutate | | .useSession(options?) | Client | Observe session data / error / isLoading; set / refresh / clear | | .query() / .mutate() | Inline | Imperative calls sharing Provider cache/session | | .prefetch() / .invalidate() | Query ops | Warm cache / invalidate observers |

Provider session.strategy:

| Strategy | Behavior | |----------|----------| | ttl (default) | Resolve session on demand, cache for ttlMs (default 30s) | | every-request | Resolve on every request | | mount | Resolve once until api.session.refresh() / reset({ scope: 'session' }) |

Merge order (call wins): session transport → api.request overlay → per-call options.

Adapter parity: build with IgniterCallerCoreAdapter so hook data / error match execute() (unwrapped payload + typed error codes). Without an adapter, hooks keep the wire schema shape.

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

export const apiManager = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withSchemas(/* … */)
  .withAdapter(IgniterCallerCoreAdapter.create())
  .build();

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