Overview
Middleware in CallApi wraps the fetch implementation to intercept requests at the network layer. Unlike hooks that run before/after requests, middleware can modify, cache, or completely replace the fetch call itself.
Fetch Middleware
CallApi provides a single powerful middleware type: fetchMiddleware.
interface Middlewares {
fetchMiddleware ?: ( context : FetchMiddlewareContext ) => FetchImpl ;
}
interface FetchMiddlewareContext extends RequestContext {
fetchImpl : FetchImpl ; // Current fetch implementation
}
type FetchImpl = (
input : string | Request | URL ,
init ?: RequestInit
) => Promise < Response >;
Source: middlewares.ts:6-56
How It Works
Middleware receives the current fetch function and returns a new one:
fetchMiddleware : ( ctx ) => {
// ctx.fetchImpl is the current fetch function
return async ( input , init ) => {
// Do something before
console . log ( 'Before fetch:' , input );
// Call the original fetch
const response = await ctx . fetchImpl ( input , init );
// Do something after
console . log ( 'After fetch:' , response . status );
return response ;
};
}
Basic Usage
Simple Logging Middleware
import { createFetchClient } from 'callapi' ;
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const start = Date . now ();
console . log ( `→ ${ init ?. method || 'GET' } ${ input } ` );
const response = await ctx . fetchImpl ( input , init );
const duration = Date . now () - start ;
console . log ( `← ${ response . status } ( ${ duration } ms)` );
return response ;
},
});
Cache Middleware
Cache GET requests in memory:
const cache = new Map < string , Response >();
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const key = input . toString ();
const method = init ?. method || 'GET' ;
// Only cache GET requests
if ( method !== 'GET' ) {
return ctx . fetchImpl ( input , init );
}
// Check cache
const cached = cache . get ( key );
if ( cached ) {
console . log ( 'Cache hit:' , key );
return cached . clone ();
}
// Fetch and cache
const response = await ctx . fetchImpl ( input , init );
cache . set ( key , response . clone ());
return response ;
},
});
Offline Mode Middleware
Handle offline scenarios gracefully:
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
// Check if online
if ( ! navigator . onLine ) {
console . warn ( 'Device is offline' );
// Return offline response
return new Response (
JSON . stringify ({ error: 'No internet connection' }),
{
status: 503 ,
statusText: 'Service Unavailable' ,
headers: { 'Content-Type' : 'application/json' },
}
);
}
return ctx . fetchImpl ( input , init );
},
});
Common Use Cases
Request Mocking
Mock specific endpoints for testing:
const mockData = {
'/users' : { id: 1 , name: 'John Doe' },
'/posts' : [{ id: 1 , title: 'Test Post' }],
};
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const url = new URL ( input . toString ());
const mockResponse = mockData [ url . pathname ];
// Return mock data if available
if ( mockResponse && process . env . NODE_ENV === 'test' ) {
return new Response ( JSON . stringify ( mockResponse ), {
status: 200 ,
headers: { 'Content-Type' : 'application/json' },
});
}
return ctx . fetchImpl ( input , init );
},
});
Rate Limiting
Implement client-side rate limiting:
class RateLimiter {
private queue : Array <() => void > = [];
private pending = 0 ;
constructor ( private maxConcurrent : number ) {}
async acquire () : Promise < void > {
if ( this . pending < this . maxConcurrent ) {
this . pending ++ ;
return ;
}
await new Promise < void >(( resolve ) => {
this . queue . push ( resolve );
});
this . pending ++ ;
}
release () : void {
this . pending -- ;
const next = this . queue . shift ();
if ( next ) next ();
}
}
const limiter = new RateLimiter ( 5 ); // Max 5 concurrent requests
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
await limiter . acquire ();
try {
return await ctx . fetchImpl ( input , init );
} finally {
limiter . release ();
}
},
});
Request Modification
Modify requests before sending:
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
// Add timestamp to all requests
const url = new URL ( input . toString ());
url . searchParams . set ( '_t' , Date . now (). toString ());
// Add custom headers
const headers = new Headers ( init ?. headers );
headers . set ( 'X-Client-Version' , '1.0.0' );
headers . set ( 'X-Request-ID' , crypto . randomUUID ());
return ctx . fetchImpl ( url . toString (), {
... init ,
headers ,
});
},
});
Modify responses before they reach hooks:
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const response = await ctx . fetchImpl ( input , init );
// Unwrap envelope responses
if ( response . ok ) {
const data = await response . json ();
// If response has envelope structure, unwrap it
if ( data . success && data . result ) {
return new Response ( JSON . stringify ( data . result ), {
status: response . status ,
statusText: response . statusText ,
headers: response . headers ,
});
}
}
return response ;
},
});
Circuit Breaker
Implement circuit breaker pattern:
class CircuitBreaker {
private failures = 0 ;
private lastFailTime = 0 ;
private state : 'closed' | 'open' | 'half-open' = 'closed' ;
constructor (
private threshold = 5 ,
private timeout = 60000
) {}
async call < T >( fn : () => Promise < T >) : Promise < T > {
if ( this . state === 'open' ) {
if ( Date . now () - this . lastFailTime > this . timeout ) {
this . state = 'half-open' ;
} else {
throw new Error ( 'Circuit breaker is OPEN' );
}
}
try {
const result = await fn ();
this . onSuccess ();
return result ;
} catch ( error ) {
this . onFailure ();
throw error ;
}
}
private onSuccess () {
this . failures = 0 ;
this . state = 'closed' ;
}
private onFailure () {
this . failures ++ ;
this . lastFailTime = Date . now ();
if ( this . failures >= this . threshold ) {
this . state = 'open' ;
}
}
}
const breaker = new CircuitBreaker ();
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware : ( ctx ) => async ( input , init ) => {
return breaker . call (() => ctx . fetchImpl ( input , init ));
},
});
Middleware Composition
Multiple middleware functions compose in order:
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
plugins: [
// Plugin middleware runs first
loggingPlugin ,
cachingPlugin ,
],
// Base middleware runs after plugin middleware
fetchMiddleware : ( ctx ) => async ( input , init ) => {
// This wraps plugin middleware
return ctx . fetchImpl ( input , init );
},
});
// Per-request middleware runs last
await callApi ( '/users' , {
fetchMiddleware : ( ctx ) => async ( input , init ) => {
// This wraps everything
return ctx . fetchImpl ( input , init );
},
});
Execution order:
Per-request middleware (outermost)
Base config middleware
Plugin middleware (in registration order)
Native fetch (innermost)
Source: middlewares.ts:72-96
Context Access
Middleware receives the full request context:
fetchMiddleware : ( ctx ) => {
// Access base configuration
console . log ( 'Base URL:' , ctx . baseConfig . baseURL );
// Access instance configuration
console . log ( 'Timeout:' , ctx . config . timeout );
// Access merged options
console . log ( 'Full URL:' , ctx . options . fullURL );
console . log ( 'Meta:' , ctx . options . meta );
// Access request object
console . log ( 'Method:' , ctx . request . method );
console . log ( 'Headers:' , ctx . request . headers );
return ctx . fetchImpl ;
}
Advanced Patterns
Conditional Middleware
Apply middleware logic conditionally:
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const url = new URL ( input . toString ());
// Only cache specific endpoints
if ( url . pathname . startsWith ( '/api/static/' )) {
return cacheMiddleware ( ctx )( input , init );
}
// Only log in development
if ( process . env . NODE_ENV === 'development' ) {
console . log ( 'Request:' , url . pathname );
}
return ctx . fetchImpl ( input , init );
}
Middleware Factory
Create reusable middleware:
function createCacheMiddleware ( ttl : number ) {
const cache = new Map ();
return ( ctx : FetchMiddlewareContext ) => async (
input : string | Request | URL ,
init ?: RequestInit
) => {
const key = input . toString ();
const cached = cache . get ( key );
if ( cached && Date . now () - cached . time < ttl ) {
return cached . response . clone ();
}
const response = await ctx . fetchImpl ( input , init );
cache . set ( key , {
response: response . clone (),
time: Date . now (),
});
return response ;
};
}
// Use the factory
const callApi = createFetchClient ({
baseURL: 'https://api.example.com' ,
fetchMiddleware: createCacheMiddleware ( 60000 ), // 1 minute TTL
});
Error Recovery
Handle and recover from errors:
fetchMiddleware : ( ctx ) => async ( input , init ) => {
try {
return await ctx . fetchImpl ( input , init );
} catch ( error ) {
console . error ( 'Fetch failed:' , error );
// Try backup server
if ( error instanceof TypeError ) {
const backupUrl = input . toString (). replace (
'api.example.com' ,
'backup.example.com'
);
return ctx . fetchImpl ( backupUrl , init );
}
throw error ;
}
}
Request Deduplication
Prevent duplicate concurrent requests:
const pendingRequests = new Map < string , Promise < Response >>();
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const key = input . toString () + JSON . stringify ( init );
// Return existing request if pending
const pending = pendingRequests . get ( key );
if ( pending ) {
console . log ( 'Deduping request:' , key );
return pending . then ( r => r . clone ());
}
// Create new request
const promise = ctx . fetchImpl ( input , init );
pendingRequests . set ( key , promise );
try {
const response = await promise ;
return response ;
} finally {
pendingRequests . delete ( key );
}
}
CallApi has built-in request deduplication. See the dedupe configuration for a more robust solution.
Middleware vs Hooks
Use middleware when you need to:
Modify or replace the fetch call itself
Cache responses at the network layer
Implement request pooling or rate limiting
Mock entire API endpoints
Transform responses before parsing
Implement circuit breakers
// ✅ Good use of middleware: Response caching
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const cached = getFromCache ( input );
if ( cached ) return cached ;
const response = await ctx . fetchImpl ( input , init );
saveToCache ( input , response . clone ());
return response ;
}
Use hooks when you need to:
Add authentication headers
Log requests/responses
Handle errors
Track progress
Validate data
Modify parsed data
// ✅ Good use of hook: Authentication
onRequest : ({ request }) => {
request . headers . Authorization = `Bearer ${ token } ` ;
}
Feature Middleware Hooks Timing Around fetch call Before/after fetch Can modify fetch Yes No Can access parsed data No Yes Can cache responses Yes Limited Multiple instances Compose Array Async support Yes Yes
Best Practices
Always Call ctx.fetchImpl
Ensure you call the original fetch implementation: // ❌ Bad: Breaks the middleware chain
fetchMiddleware : ( ctx ) => async ( input , init ) => {
return fetch ( input , init ); // Bypasses other middleware!
}
// ✅ Good: Calls through the chain
fetchMiddleware : ( ctx ) => async ( input , init ) => {
return ctx . fetchImpl ( input , init );
}
Clone Responses When Caching
Response bodies can only be read once: // ❌ Bad: Response already consumed
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const response = await ctx . fetchImpl ( input , init );
cache . set ( key , response );
return response ; // Error: body already read!
}
// ✅ Good: Clone the response
fetchMiddleware : ( ctx ) => async ( input , init ) => {
const response = await ctx . fetchImpl ( input , init );
cache . set ( key , response . clone ());
return response ;
}
Don’t let middleware errors crash requests: fetchMiddleware : ( ctx ) => async ( input , init ) => {
try {
// Try cache first
const cached = await getFromCache ( input );
if ( cached ) return cached ;
} catch ( error ) {
// Log but continue
console . error ( 'Cache error:' , error );
}
// Always fall back to actual fetch
return ctx . fetchImpl ( input , init );
}
Each middleware should have a single responsibility: // ❌ Bad: Too many responsibilities
fetchMiddleware : ( ctx ) => async ( input , init ) => {
// Caching AND logging AND retries AND...
}
// ✅ Good: Separate concerns
const callApi = createFetchClient ({
plugins: [
cachePlugin ,
loggerPlugin ,
retryPlugin ,
],
});
TypeScript Support
Middleware is fully typed:
import type { FetchMiddlewareContext , FetchImpl } from 'callapi' ;
const myMiddleware = (
ctx : FetchMiddlewareContext
) : FetchImpl => {
return async ( input , init ) => {
// TypeScript knows the types
return ctx . fetchImpl ( input , init );
};
};
const callApi = createFetchClient ({
fetchMiddleware: myMiddleware ,
});
Use the provided types for better IDE support and type safety.