# Chapter 8: Interceptors & Middleware *"Every request tells a story. Interceptors let you edit it."* --- ## The Authentication Nightmare Sarah's Weather Buddy was type-safe, cached, and error-resistant. But then came the new requirement: user authentication for premium features. "Every API call needs an auth token now," her PM announced. "And we need to track all requests for analytics. Oh, and can you add request signing for the payment endpoints?" Sarah's heart sank. Would she have to modify every single API call? "Not with interceptors," Marcus said, appearing with coffee. "Let me show you the power of middleware." ## Understanding Interceptors: The Request Pipeline Think of interceptors like airport security. Every request passes through checkpoints where you can inspect, modify, or reject it: ```javascript // Without interceptors - repetitive and error-prone const token = getAuthToken() const { data } = await tf.get('/api/user', { headers: { 'Authorization': `Bearer ${token}`, 'X-Request-ID': generateId(), 'X-Client-Version': APP_VERSION } }) logRequest('/api/user') // With interceptors - clean and automatic tf.addRequestInterceptor(config => ({ ...config, headers: { ...config.headers, 'Authorization': `Bearer ${getAuthToken()}`, 'X-Request-ID': generateId(), 'X-Client-Version': APP_VERSION } })) // Now every request is automatically enhanced! const { data } = await tf.get('/api/user') // Auth added automatically ``` ## Request Interceptors: Transform Outgoing Requests Request interceptors run before requests are sent: ```typescript // Add authentication to all requests tf.addRequestInterceptor(config => { const token = localStorage.getItem('authToken') if (token && !config.headers['Authorization']) { config.headers['Authorization'] = `Bearer ${token}` } return config }) // Add request tracking tf.addRequestInterceptor(config => { config.metadata = { ...config.metadata, requestId: crypto.randomUUID(), timestamp: Date.now(), userId: getCurrentUserId() } console.log(`[${config.metadata.requestId}] ${config.method} ${config.url}`) return config }) // Modify requests based on environment tf.addRequestInterceptor(config => { if (process.env.NODE_ENV === 'development') { // Add debug headers in dev config.headers['X-Debug-Mode'] = 'true' config.headers['X-Developer'] = os.username() } if (config.url.includes('/payment')) { // Extra security for payment endpoints config.timeout = 30000 // 30 seconds config.retries = 0 // No retries for payments } return config }) // Request signing for secure endpoints tf.addRequestInterceptor(async config => { if (config.url.includes('/secure')) { const signature = await signRequest(config) config.headers['X-Signature'] = signature config.headers['X-Timestamp'] = Date.now().toString() } return config }) ``` ## Response Interceptors: Transform Incoming Data Response interceptors process data before it reaches your code: ```typescript // Transform snake_case to camelCase tf.addResponseInterceptor(response => { if (response.data) { response.data = transformKeys(response.data, snakeToCamel) } return response }) // Extract data from envelopes tf.addResponseInterceptor(response => { // API returns { success: true, data: {...}, meta: {...} } // We just want the data if (response.data?.success && response.data?.data) { response.data = response.data.data } return response }) // Add computed properties tf.addResponseInterceptor(response => { if (response.config.url.includes('/users')) { // Add display name if (response.data.firstName && response.data.lastName) { response.data.displayName = `${response.data.firstName} ${response.data.lastName}` } // Add avatar URL if (response.data.avatarId && !response.data.avatarUrl) { response.data.avatarUrl = `https://cdn.example.com/avatars/${response.data.avatarId}` } } return response }) // Track response times tf.addResponseInterceptor(response => { const duration = Date.now() - response.config.metadata.timestamp console.log( `[${response.config.metadata.requestId}] ` + `${response.status} ${response.config.url} - ${duration}ms` ) // Add to metrics metrics.recordApiCall({ endpoint: response.config.url, method: response.config.method, status: response.status, duration }) return response }) ``` ## Error Interceptors: Handle Failures Gracefully Error interceptors catch and transform errors: ```typescript // Retry on auth failure tf.addErrorInterceptor(async error => { if (error.response?.status === 401) { // Try refreshing the token try { const newToken = await refreshAuthToken() localStorage.setItem('authToken', newToken) // Retry the original request error.config.headers['Authorization'] = `Bearer ${newToken}` return tf.request(error.config) } catch (refreshError) { // Refresh failed, redirect to login window.location.href = '/login' throw error } } throw error }) // Transform error messages tf.addErrorInterceptor(error => { // Make errors more user-friendly if (error.response?.status === 404) { error.userMessage = 'The requested resource was not found.' error.suggestions = ['Check the URL', 'Try again later'] } else if (error.response?.status === 500) { error.userMessage = 'Something went wrong on our end.' error.suggestions = ['Try again in a few minutes', 'Contact support'] } // Log to error service errorReporter.log(error) throw error }) // Handle network errors tf.addErrorInterceptor(error => { if (error.code === 'NETWORK_ERROR') { // Check if we're offline if (!navigator.onLine) { error.userMessage = 'You appear to be offline.' error.canRetry = true // Queue for later offlineQueue.add(error.config) } } throw error }) ``` ## Building Complex Middleware Chains Interceptors can work together to create powerful pipelines: ```typescript // 1. Authentication interceptor class AuthInterceptor { constructor(private auth: AuthService) {} async request(config: RequestConfig) { const token = await this.auth.getToken() if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config } async error(error: Error) { if (error.response?.status === 401) { await this.auth.refresh() return tf.request(error.config) } throw error } } // 2. Logging interceptor class LoggingInterceptor { private logger = new Logger('API') request(config: RequestConfig) { this.logger.info(`→ ${config.method} ${config.url}`) config.metadata.startTime = performance.now() return config } response(response: Response) { const duration = performance.now() - response.config.metadata.startTime this.logger.info( `← ${response.status} ${response.config.url} (${duration.toFixed(2)}ms)` ) return response } error(error: Error) { this.logger.error(`✗ ${error.config.url}: ${error.message}`) throw error } } // 3. Retry interceptor class RetryInterceptor { private retryCount = new Map() async error(error: Error) { const key = `${error.config.method}:${error.config.url}` const attempts = this.retryCount.get(key) || 0 if (this.shouldRetry(error) && attempts < 3) { this.retryCount.set(key, attempts + 1) await this.delay(attempts) return tf.request(error.config) } this.retryCount.delete(key) throw error } private shouldRetry(error: Error) { return ( error.response?.status >= 500 || error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT' ) } private delay(attempt: number) { const ms = Math.min(1000 * Math.pow(2, attempt), 10000) return new Promise(resolve => setTimeout(resolve, ms)) } } // Register all interceptors const auth = new AuthInterceptor(authService) const logging = new LoggingInterceptor() const retry = new RetryInterceptor() tf.addRequestInterceptor(config => auth.request(config)) tf.addRequestInterceptor(config => logging.request(config)) tf.addResponseInterceptor(response => logging.response(response)) tf.addErrorInterceptor(error => auth.error(error)) tf.addErrorInterceptor(error => retry.error(error)) tf.addErrorInterceptor(error => logging.error(error)) ``` ## Weather Buddy 8.0: Enterprise Ready Let's add professional-grade interceptors to Weather Buddy: ```typescript // weather-buddy-8.ts import { tf } from 'typedfetch' // Configuration interface Config { apiKey: string analyticsId: string environment: 'development' | 'staging' | 'production' } const config: Config = { apiKey: process.env.WEATHER_API_KEY!, analyticsId: process.env.ANALYTICS_ID!, environment: (process.env.NODE_ENV as any) || 'development' } // Analytics tracking class AnalyticsInterceptor { private queue: AnalyticsEvent[] = [] private flushInterval = 5000 constructor(private analyticsId: string) { setInterval(() => this.flush(), this.flushInterval) } request(config: RequestConfig) { config.metadata.analyticsId = crypto.randomUUID() config.metadata.timestamp = Date.now() this.track({ type: 'api_request', id: config.metadata.analyticsId, endpoint: config.url, method: config.method, timestamp: config.metadata.timestamp }) return config } response(response: Response) { const duration = Date.now() - response.config.metadata.timestamp this.track({ type: 'api_response', id: response.config.metadata.analyticsId, endpoint: response.config.url, status: response.status, duration, cached: response.cached || false }) return response } error(error: Error) { this.track({ type: 'api_error', id: error.config.metadata.analyticsId, endpoint: error.config.url, error: error.message, status: error.response?.status }) throw error } private track(event: AnalyticsEvent) { this.queue.push({ ...event, analyticsId: this.analyticsId, sessionId: getSessionId(), userId: getCurrentUserId() }) } private async flush() { if (this.queue.length === 0) return const events = [...this.queue] this.queue = [] try { await fetch('https://analytics.example.com/events', { method: 'POST', body: JSON.stringify({ events }) }) } catch (error) { // Re-queue on failure this.queue.unshift(...events) } } } // API versioning class VersioningInterceptor { constructor(private version: string) {} request(config: RequestConfig) { // Add version to URL if (config.url.includes('/api/')) { config.url = config.url.replace('/api/', `/api/${this.version}/`) } // Add version header config.headers['API-Version'] = this.version return config } response(response: Response) { // Check if API version is deprecated const deprecation = response.headers.get('X-API-Deprecation') if (deprecation) { console.warn(`API deprecation warning: ${deprecation}`) // Show user notification showNotification({ type: 'warning', message: 'This app needs to be updated soon.', action: 'Update Now', onAction: () => window.location.href = '/update' }) } return response } } // Request signing for premium features class SigningInterceptor { constructor(private secret: string) {} async request(config: RequestConfig) { if (config.url.includes('/premium')) { const timestamp = Date.now() const payload = `${config.method}:${config.url}:${timestamp}` const signature = await this.sign(payload) config.headers['X-Signature'] = signature config.headers['X-Timestamp'] = timestamp.toString() } return config } private async sign(payload: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(payload) const key = await crypto.subtle.importKey( 'raw', encoder.encode(this.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const signature = await crypto.subtle.sign('HMAC', key, data) return btoa(String.fromCharCode(...new Uint8Array(signature))) } } // Rate limiting with backpressure class RateLimitInterceptor { private requests = new Map() private limits = { '/api/weather': { requests: 60, window: 60000 }, // 60/minute '/api/premium': { requests: 100, window: 60000 }, // 100/minute 'default': { requests: 120, window: 60000 } // 120/minute } async request(config: RequestConfig) { const endpoint = this.getEndpoint(config.url) const limit = this.limits[endpoint] || this.limits.default const now = Date.now() const requests = this.requests.get(endpoint) || [] // Clean old requests const recent = requests.filter(time => now - time < limit.window) if (recent.length >= limit.requests) { const oldestRequest = recent[0] const waitTime = limit.window - (now - oldestRequest) console.warn(`Rate limit approaching, waiting ${waitTime}ms`) await new Promise(resolve => setTimeout(resolve, waitTime)) } recent.push(now) this.requests.set(endpoint, recent) return config } response(response: Response) { // Check rate limit headers const remaining = response.headers.get('X-RateLimit-Remaining') const reset = response.headers.get('X-RateLimit-Reset') if (remaining && parseInt(remaining) < 10) { console.warn(`Low API quota: ${remaining} requests remaining`) // Slow down requests tf.configure({ requestDelay: 1000 // Add 1s delay between requests }) } return response } private getEndpoint(url: string): string { const match = url.match(/\/api\/\w+/) return match ? match[0] : 'default' } } // Development tools class DevToolsInterceptor { private enabled = config.environment === 'development' private requests: RequestLog[] = [] request(config: RequestConfig) { if (!this.enabled) return config const log: RequestLog = { id: config.metadata.requestId, method: config.method, url: config.url, headers: config.headers, data: config.data, timestamp: Date.now(), stack: new Error().stack } this.requests.push(log) // Dev UI integration window.postMessage({ type: 'API_REQUEST', payload: log }, '*') return config } response(response: Response) { if (!this.enabled) return response const log = this.requests.find(r => r.id === response.config.metadata.requestId) if (log) { log.response = { status: response.status, headers: Object.fromEntries(response.headers.entries()), data: response.data, duration: Date.now() - log.timestamp } window.postMessage({ type: 'API_RESPONSE', payload: log }, '*') } return response } getRequests() { return this.requests } clear() { this.requests = [] } } // Register all interceptors const analytics = new AnalyticsInterceptor(config.analyticsId) const versioning = new VersioningInterceptor('v2') const signing = new SigningInterceptor(config.apiKey) const rateLimit = new RateLimitInterceptor() const devTools = new DevToolsInterceptor() // Request pipeline tf.addRequestInterceptor(config => versioning.request(config)) tf.addRequestInterceptor(config => analytics.request(config)) tf.addRequestInterceptor(config => rateLimit.request(config)) tf.addRequestInterceptor(config => signing.request(config)) tf.addRequestInterceptor(config => devTools.request(config)) // Response pipeline tf.addResponseInterceptor(response => devTools.response(response)) tf.addResponseInterceptor(response => analytics.response(response)) tf.addResponseInterceptor(response => versioning.response(response)) tf.addResponseInterceptor(response => rateLimit.response(response)) // Error pipeline tf.addErrorInterceptor(error => analytics.error(error)) // Weather Buddy Premium Features class WeatherBuddyPremium { private subscriptionActive = false async checkSubscription(): Promise { try { const { data } = await tf.get('/api/premium/subscription') this.subscriptionActive = data.active return data.active } catch { return false } } async getDetailedForecast(city: string): Promise { if (!this.subscriptionActive) { throw new Error('Premium subscription required') } const { data } = await tf.get(`/api/premium/forecast/${city}`, { params: { days: 14, hourly: true, includes: ['uv', 'pollen', 'airQuality', 'astronomy'] } }) return data } async getWeatherAlerts(location: { lat: number, lon: number }): Promise { const { data } = await tf.get('/api/premium/alerts', { params: location }) return data.alerts } async getHistoricalWeather(city: string, date: Date): Promise { const { data } = await tf.get(`/api/premium/historical/${city}`, { params: { date: date.toISOString().split('T')[0] } }) return data } } // Export for DevTools extension if (config.environment === 'development') { (window as any).__WEATHER_BUDDY_DEV__ = { tf, interceptors: { analytics, versioning, signing, rateLimit, devTools }, getRequests: () => devTools.getRequests(), clearRequests: () => devTools.clear(), config } } ``` ## Creating Plugin Systems with Interceptors Build extensible applications with interceptor-based plugins: ```typescript // Plugin system interface Plugin { name: string version: string init?(tf: TypedFetch): void request?(config: RequestConfig): RequestConfig | Promise response?(response: Response): Response | Promise error?(error: Error): Error | Promise } class PluginManager { private plugins: Plugin[] = [] register(plugin: Plugin) { console.log(`Loading plugin: ${plugin.name} v${plugin.version}`) this.plugins.push(plugin) if (plugin.init) { plugin.init(tf) } if (plugin.request) { tf.addRequestInterceptor(config => plugin.request!(config)) } if (plugin.response) { tf.addResponseInterceptor(response => plugin.response!(response)) } if (plugin.error) { tf.addErrorInterceptor(error => plugin.error!(error)) } } unregister(pluginName: string) { this.plugins = this.plugins.filter(p => p.name !== pluginName) // Note: Interceptors can't be removed in this example // In real implementation, track interceptor IDs } list() { return this.plugins.map(p => ({ name: p.name, version: p.version })) } } // Example plugins const compressionPlugin: Plugin = { name: 'compression', version: '1.0.0', request(config) { if (config.data && config.method !== 'GET') { const json = JSON.stringify(config.data) if (json.length > 1024) { // Compress large payloads config.headers['Content-Encoding'] = 'gzip' config.data = gzip(json) } } return config }, response(response) { if (response.headers.get('Content-Encoding') === 'gzip') { response.data = JSON.parse(gunzip(response.data)) } return response } } const cacheSyncPlugin: Plugin = { name: 'cache-sync', version: '1.0.0', init(tf) { // Sync cache across tabs window.addEventListener('storage', (e) => { if (e.key?.startsWith('tf-cache-')) { tf.cache.sync(e.key, e.newValue) } }) }, response(response) { if (response.config.method === 'GET' && response.cached === false) { // Share across tabs localStorage.setItem( `tf-cache-${response.config.url}`, JSON.stringify({ data: response.data, timestamp: Date.now() }) ) } return response } } // Use plugins const plugins = new PluginManager() plugins.register(compressionPlugin) plugins.register(cacheSyncPlugin) ``` ## Advanced Interceptor Patterns ### 1. Conditional Interceptors Apply interceptors based on conditions: ```typescript // Only for specific endpoints tf.addRequestInterceptor(config => { if (config.url.includes('/admin')) { config.headers['X-Admin-Token'] = getAdminToken() } return config }) // Only in certain environments if (process.env.NODE_ENV === 'production') { tf.addRequestInterceptor(config => { config.headers['X-Source'] = 'production' return config }) } // Based on feature flags tf.addRequestInterceptor(config => { if (featureFlags.get('new-api')) { config.url = config.url.replace('/api/v1', '/api/v2') } return config }) ``` ### 2. Interceptor Composition Combine multiple interceptors elegantly: ```typescript function compose(...interceptors: RequestInterceptor[]) { return async (config: RequestConfig) => { let result = config for (const interceptor of interceptors) { result = await interceptor(result) } return result } } // Combine auth + logging + metrics const combined = compose( addAuth, addLogging, addMetrics ) tf.addRequestInterceptor(combined) ``` ### 3. Stateful Interceptors Interceptors that maintain state: ```typescript class SequenceInterceptor { private sequence = 0 request(config: RequestConfig) { config.headers['X-Sequence'] = (++this.sequence).toString() return config } response(response: Response) { const expected = response.config.headers['X-Sequence'] const received = response.headers.get('X-Sequence-Echo') if (expected !== received) { console.warn('Response out of sequence!') } return response } } ``` ### 4. Priority-Based Interceptors Control interceptor execution order: ```typescript class PriorityInterceptorManager { private interceptors: Array<{ priority: number handler: Function }> = [] add(handler: Function, priority = 0) { this.interceptors.push({ priority, handler }) this.interceptors.sort((a, b) => b.priority - a.priority) } async execute(config: RequestConfig) { let result = config for (const { handler } of this.interceptors) { result = await handler(result) } return result } } // Usage const manager = new PriorityInterceptorManager() manager.add(authInterceptor, 100) // Run first manager.add(loggingInterceptor, 50) // Run second manager.add(metricsInterceptor, 10) // Run last ``` ## Testing with Interceptors Use interceptors to make testing easier: ```typescript // Mock interceptor for testing class MockInterceptor { private mocks = new Map() mock(url: string, response: any) { this.mocks.set(url, response) } clear() { this.mocks.clear() } request(config: RequestConfig) { const mock = this.mocks.get(config.url) if (mock) { // Bypass network, return mock return Promise.reject({ config, mockResponse: mock }) } return config } error(error: any) { if (error.mockResponse) { // Convert to response return { config: error.config, data: error.mockResponse, status: 200, headers: new Headers(), mocked: true } } throw error } } // Testing describe('WeatherAPI', () => { const mock = new MockInterceptor() beforeAll(() => { tf.addRequestInterceptor(config => mock.request(config)) tf.addErrorInterceptor(error => mock.error(error)) }) beforeEach(() => { mock.clear() }) test('getWeather returns data', async () => { mock.mock('/api/weather/london', { temp: 15, condition: 'Cloudy' }) const weather = await getWeather('london') expect(weather.temp).toBe(15) }) }) ``` ## Best Practices for Interceptors 🎯 ### 1. Keep Interceptors Focused ```typescript // ✅ Good: Single responsibility tf.addRequestInterceptor(addAuthentication) tf.addRequestInterceptor(addRequestId) tf.addRequestInterceptor(addVersioning) // ❌ Bad: Doing too much tf.addRequestInterceptor(config => { // Add auth // Add logging // Add versioning // Transform data // 100 lines of code... }) ``` ### 2. Handle Errors Gracefully ```typescript // ✅ Good: Safe interceptor tf.addRequestInterceptor(async config => { try { const token = await getToken() config.headers['Authorization'] = `Bearer ${token}` } catch (error) { console.error('Failed to get token:', error) // Continue without auth rather than failing } return config }) ``` ### 3. Make Interceptors Configurable ```typescript // ✅ Good: Configurable behavior function createLoggingInterceptor(options = {}) { const { logLevel = 'info', includeHeaders = false, includeBody = false } = options return (config: RequestConfig) => { const log = { method: config.method, url: config.url, ...(includeHeaders && { headers: config.headers }), ...(includeBody && { body: config.data }) } logger[logLevel]('API Request:', log) return config } } ``` ### 4. Document Side Effects ```typescript /** * Adds authentication to requests * Side effects: * - Reads from localStorage * - May redirect to /login on 401 * - Refreshes token automatically */ function authInterceptor(config: RequestConfig) { // Implementation } ``` ## Practice Time! 🏋️ ### Exercise 1: Build a Caching Interceptor Create an interceptor that caches responses: ```typescript class CacheInterceptor { // Your code here: // - Cache GET responses // - Respect cache headers // - Handle cache invalidation // - Add cache info to response } ``` ### Exercise 2: Create a Retry Queue Build an interceptor that queues failed requests: ```typescript class RetryQueueInterceptor { // Your code here: // - Queue failed requests // - Retry with backoff // - Handle offline/online // - Prevent duplicates } ``` ### Exercise 3: Request Batching Implement request batching: ```typescript class BatchInterceptor { // Your code here: // - Batch multiple requests // - Send as single request // - Distribute responses // - Handle partial failures } ``` ## Key Takeaways 🎯 1. **Interceptors modify requests and responses in flight** - Transform data automatically 2. **Request interceptors run before sending** - Add auth, headers, tracking 3. **Response interceptors run after receiving** - Transform data, add computed fields 4. **Error interceptors handle failures** - Retry, refresh tokens, queue offline 5. **Chain interceptors for complex behaviors** - Compose simple functions 6. **Use interceptors for cross-cutting concerns** - Auth, logging, analytics 7. **Interceptors enable plugin systems** - Extensible architectures 8. **Keep interceptors focused and safe** - Single responsibility, graceful errors ## Common Pitfalls 🚨 1. **Modifying config without cloning** - Always return new object 2. **Forgetting async interceptors** - Handle promises properly 3. **Interceptor order matters** - Auth before signing 4. **Infinite loops in error handlers** - Detect retry loops 5. **Heavy processing in interceptors** - Keep them fast 6. **Not documenting side effects** - Hidden behaviors confuse ## What's Next? You've mastered interceptors! But what about real-time data? In Chapter 9, we'll explore streaming and real-time features: - Server-Sent Events (SSE) - WebSocket integration - Streaming JSON responses - Real-time data synchronization - Live updates and subscriptions - Handling connection states Ready to make your app real-time? See you in Chapter 9! 🚀 --- ## Chapter Summary - Interceptors form a pipeline that every request/response passes through - Request interceptors add auth, headers, and modify outgoing data - Response interceptors transform data and add computed properties - Error interceptors handle failures, retry logic, and token refresh - Chain multiple interceptors for complex behaviors like analytics + auth + versioning - Interceptors enable plugin systems and extensible architectures - Keep interceptors focused, safe, and well-documented - Weather Buddy 8.0 is enterprise-ready with auth, analytics, versioning, and rate limiting **Next Chapter Preview**: Real-Time & Streaming - Server-Sent Events, WebSockets, streaming responses, and building live-updating applications.