@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)
Maintainers
Readme
@igniter-js/caller
End-to-end type-safe HTTP client
Built on fetch with interceptors, retries, caching, schema validation, and full observability.
Quick Start • Documentation • Examples • API 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
fetchworks (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 cleanresult.ok/result.dataDX - ✅ 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/context → session; client invalidateAll/clear/refreshContext → reset / session / request; useMutate → useMutation; 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/callerOptional dependencies:
# For schema validation (any StandardSchemaV1 library)
npm install zod
# For telemetry features
npm install @igniter-js/telemetryNote:
@igniter-js/commonis installed automatically.zodis optional for schema validation. Install@igniter-js/telemetryif you plan to usewithTelemetry()or@igniter-js/caller/telemetry. Installreact(>=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=1Request 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 erroraxios-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 continueoff: 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.errorTelemetry 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(); // ✅ TypedError: 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
onEventlifecycle (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 resetClient 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
- Use caching aggressively for read-heavy endpoints
- Enable store-based caching (Redis) for distributed systems
- Batch parallel requests with
IgniterCallerManager.batch() - Set appropriate timeouts to fail fast
- Use retry with exponential backoff for transient failures
- Minimize interceptor overhead (avoid heavy computation)
- 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
- @igniter-js/core — HTTP framework core
- @igniter-js/telemetry — Observability system
- @igniter-js/store — State management
- Igniter.js Documentation
💬 Community & Support
Built with ❤️ by the Igniter.js team
