Advanced

The Aurelia Fetch Client offers powerful advanced features for enterprise applications, including sophisticated caching, request monitoring, custom interceptor patterns, and integration with complex authentication systems.

Advanced Header Management

Dynamic Authorization Headers

Headers can be functions that are evaluated for each request, perfect for handling token refresh scenarios:

import { IHttpClient } from '@aurelia/fetch-client';
import { resolve } from '@aurelia/kernel';

export class AuthenticatedApiService {
  private http = resolve(IHttpClient);
  private tokenStorage = new TokenStorage();

  constructor() {
    this.http.configure(config => config
      .withDefaults({
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
          'Authorization': () => {
            const token = this.tokenStorage.getAccessToken();
            return token ? `Bearer ${token}` : '';
          },
          'X-Client-Version': () => this.getClientVersion(),
          'X-Request-ID': () => this.generateRequestId()
        }
      })
    );
  }

  private getClientVersion(): string {
    return process.env.APP_VERSION || '1.0.0';
  }

  private generateRequestId(): string {
    return `${Date.now()}-${Math.random().toString(36).substring(2)}`;
  }
}

class TokenStorage {
  getAccessToken(): string | null {
    const tokenData = localStorage.getItem('auth_tokens');
    if (!tokenData) return null;

    const { accessToken, expiresAt } = JSON.parse(tokenData);
    
    // Check if token is expired
    if (Date.now() >= expiresAt) {
      this.refreshToken();
      return this.getAccessToken(); // Recursive call after refresh
    }

    return accessToken;
  }

  private refreshToken(): void {
    // Token refresh logic here
    // This is synchronous for simplicity, but could be async
  }
}

Conditional Headers

Apply different headers based on request characteristics:

export class ConditionalHeaderService {
  private http = resolve(IHttpClient);

  constructor() {
    this.http.configure(config => config.withInterceptor({
      request(request) {
        const url = new URL(request.url);
        
        // Add API key for external APIs
        if (url.hostname !== window.location.hostname) {
          request.headers.set('X-API-Key', this.getExternalApiKey(url.hostname));
        }
        
        // Add CSRF token for state-changing operations
        if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
          const csrfToken = this.getCsrfToken();
          if (csrfToken) {
            request.headers.set('X-CSRF-Token', csrfToken);
          }
        }
        
        // Add correlation ID for internal services
        if (url.pathname.startsWith('/api/internal/')) {
          request.headers.set('X-Correlation-ID', this.generateCorrelationId());
        }

        return request;
      }
    }));
  }

  private getExternalApiKey(hostname: string): string {
    const keyMap = {
      'api.external1.com': process.env.EXTERNAL_API_1_KEY,
      'api.external2.com': process.env.EXTERNAL_API_2_KEY
    };
    return keyMap[hostname] || '';
  }

  private getCsrfToken(): string {
    return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
  }

  private generateCorrelationId(): string {
    return `corr-${Date.now()}-${Math.random().toString(36).substring(2)}`;
  }
}

Built-in Cache Interceptor

The fetch client includes a sophisticated caching system with multiple storage options:

import { IHttpClient, CacheInterceptor } from '@aurelia/fetch-client';
import { DI } from '@aurelia/kernel';

export class CachedApiService {
  private http = resolve(IHttpClient);

  constructor() {
    this.setupCaching();
  }

  private setupCaching() {
    // Create cache interceptor with configuration
    const cacheInterceptor = DI.getGlobalContainer().invoke(CacheInterceptor, [{
      cacheTime: 300_000,        // Cache for 5 minutes
      staleTime: 60_000,         // Data becomes stale after 1 minute
      refreshStaleImmediate: false, // Don't block on stale refresh
      refreshInterval: 30_000,   // Background refresh every 30 seconds
    }]);

    this.http.configure(config => config.withInterceptor(cacheInterceptor));
  }

  // Cache interceptor automatically handles these GET requests
  async getUserProfile(userId: string) {
    const response = await this.http.get(`/api/users/${userId}`);
    return response.json();
  }

  async getStaticData() {
    // This will be cached and refreshed in background
    const response = await this.http.get('/api/config/static');
    return response.json();
  }
}

Custom Cache Storage

Use different storage backends for caching:

import { 
  CacheInterceptor, 
  BrowserLocalStorage, 
  BrowserSessionStorage,
  BrowserIndexDBStorage,
  MemoryStorage 
} from '@aurelia/fetch-client';

export class CustomCacheService {
  private http = resolve(IHttpClient);

  setupPersistentCaching() {
    // Use localStorage for persistent caching across sessions
    const persistentCache = DI.getGlobalContainer().invoke(CacheInterceptor, [{
      cacheTime: 3600_000, // 1 hour
      storage: new BrowserLocalStorage()
    }]);

    this.http.configure(config => config.withInterceptor(persistentCache));
  }

  setupSessionCaching() {
    // Use sessionStorage for session-only caching
    const sessionCache = DI.getGlobalContainer().invoke(CacheInterceptor, [{
      cacheTime: 1800_000, // 30 minutes
      storage: new BrowserSessionStorage()
    }]);

    this.http.configure(config => config.withInterceptor(sessionCache));
  }

  setupIndexDBCaching() {
    // Use IndexedDB for large data caching
    const indexDbCache = DI.getGlobalContainer().invoke(CacheInterceptor, [{
      cacheTime: 7200_000, // 2 hours
      storage: new BrowserIndexDBStorage()
    }]);

    this.http.configure(config => config.withInterceptor(indexDbCache));
  }
}

Request Event Monitoring

Monitor and respond to request lifecycle events:

export class RequestMonitoringService {
  private http = resolve(IHttpClient);
  private activeRequests = new Set<string>();

  constructor() {
    this.setupEventMonitoring();
    this.setupRequestTracking();
  }

  private setupEventMonitoring() {
    // Configure event dispatcher
    this.http.configure(config => config.withDispatcher(document.body));

    // Listen for request lifecycle events
    document.body.addEventListener('aurelia-fetch-client-request-started', (event: CustomEvent) => {
      console.log('Request started:', event.detail);
      this.showLoadingIndicator();
    });

    document.body.addEventListener('aurelia-fetch-client-requests-drained', () => {
      console.log('All requests completed');
      this.hideLoadingIndicator();
    });
  }

  private setupRequestTracking() {
    this.http.configure(config => config.withInterceptor({
      request: (request) => {
        const requestId = this.generateRequestId();
        this.activeRequests.add(requestId);
        
        // Add tracking header
        request.headers.set('X-Request-ID', requestId);
        
        console.log(`Starting request ${requestId}: ${request.method} ${request.url}`);
        return request;
      },
      
      response: (response, request) => {
        const requestId = request?.headers.get('X-Request-ID');
        if (requestId) {
          this.activeRequests.delete(requestId);
          console.log(`Completed request ${requestId}: ${response.status}`);
        }
        return response;
      },
      
      responseError: (error, request) => {
        const requestId = request?.headers.get('X-Request-ID');
        if (requestId) {
          this.activeRequests.delete(requestId);
          console.error(`Failed request ${requestId}:`, error);
        }
        throw error;
      }
    }));
  }

  private showLoadingIndicator() {
    // Show global loading indicator
    document.body.classList.add('loading');
  }

  private hideLoadingIndicator() {
    // Hide global loading indicator
    document.body.classList.remove('loading');
  }

  private generateRequestId(): string {
    return `req-${Date.now()}-${Math.random().toString(36).substring(2)}`;
  }

  // Public API for monitoring
  getActiveRequestCount(): number {
    return this.activeRequests.size;
  }

  isRequestActive(): boolean {
    return this.activeRequests.size > 0;
  }
}

Advanced Authentication Patterns

Automatic Token Refresh

Handle token expiration and refresh transparently:

export class TokenRefreshService {
  private http = resolve(IHttpClient);
  private refreshPromise: Promise<string> | null = null;

  constructor() {
    this.setupTokenRefresh();
  }

  private setupTokenRefresh() {
    this.http.configure(config => config.withInterceptor({
      async responseError(error, request, client) {
        if (error instanceof Response && error.status === 401) {
          // Token expired, try to refresh
          try {
            const newToken = await this.refreshAccessToken();
            
            // Retry original request with new token
            const newRequest = new Request(request.url, {
              method: request.method,
              headers: {
                ...Object.fromEntries(request.headers.entries()),
                'Authorization': `Bearer ${newToken}`
              },
              body: request.body
            });
            
            return client.fetch(newRequest);
          } catch (refreshError) {
            // Refresh failed, redirect to login
            this.redirectToLogin();
            throw error;
          }
        }
        
        throw error;
      }
    }));
  }

  private async refreshAccessToken(): Promise<string> {
    // Prevent multiple simultaneous refresh attempts
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    this.refreshPromise = this.performTokenRefresh();
    
    try {
      const token = await this.refreshPromise;
      return token;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async performTokenRefresh(): Promise<string> {
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) {
      throw new Error('No refresh token available');
    }

    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken })
    });

    if (!response.ok) {
      throw new Error('Token refresh failed');
    }

    const { accessToken, refreshToken: newRefreshToken } = await response.json();
    
    // Store new tokens
    this.storeTokens(accessToken, newRefreshToken);
    
    return accessToken;
  }

  private getRefreshToken(): string | null {
    const tokens = localStorage.getItem('auth_tokens');
    return tokens ? JSON.parse(tokens).refreshToken : null;
  }

  private storeTokens(accessToken: string, refreshToken: string) {
    const tokens = {
      accessToken,
      refreshToken,
      expiresAt: Date.now() + (55 * 60 * 1000) // 55 minutes
    };
    localStorage.setItem('auth_tokens', JSON.stringify(tokens));
  }

  private redirectToLogin() {
    localStorage.removeItem('auth_tokens');
    window.location.href = '/login';
  }
}

Request Batching and Coordination

Request Deduplication

Prevent duplicate concurrent requests:

export class RequestDeduplicationService {
  private http = resolve(IHttpClient);
  private pendingRequests = new Map<string, Promise<Response>>();

  constructor() {
    this.setupDeduplication();
  }

  private setupDeduplication() {
    this.http.configure(config => config.withInterceptor({
      request: (request) => {
        // Only deduplicate GET requests
        if (request.method !== 'GET') {
          return request;
        }

        const key = this.getRequestKey(request);
        const existingRequest = this.pendingRequests.get(key);

        if (existingRequest) {
          // Return the existing request's promise
          return existingRequest.then(response => response.clone());
        }

        // Store the request promise
        const requestPromise = fetch(request).then(response => {
          // Clean up when request completes
          this.pendingRequests.delete(key);
          return response;
        }).catch(error => {
          // Clean up on error too
          this.pendingRequests.delete(key);
          throw error;
        });

        this.pendingRequests.set(key, requestPromise);
        
        // Return the request to continue normal processing
        return request;
      }
    }));
  }

  private getRequestKey(request: Request): string {
    // Create unique key based on URL and headers
    const headers = Array.from(request.headers.entries()).sort();
    return `${request.method}:${request.url}:${JSON.stringify(headers)}`;
  }
}

Request Queuing

Queue and coordinate multiple requests:

export class RequestQueueService {
  private http = resolve(IHttpClient);
  private requestQueue: Array<() => Promise<any>> = [];
  private isProcessing = false;
  private maxConcurrent = 3;
  private activeRequests = 0;

  async queueRequest<T>(requestFn: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.requestQueue.push(async () => {
        try {
          const result = await requestFn();
          resolve(result);
        } catch (error) {
          reject(error);
        }
      });

      this.processQueue();
    });
  }

  private async processQueue() {
    if (this.isProcessing || this.activeRequests >= this.maxConcurrent) {
      return;
    }

    const request = this.requestQueue.shift();
    if (!request) {
      return;
    }

    this.isProcessing = true;
    this.activeRequests++;

    try {
      await request();
    } finally {
      this.activeRequests--;
      this.isProcessing = false;
      
      // Process next request in queue
      if (this.requestQueue.length > 0) {
        setTimeout(() => this.processQueue(), 0);
      }
    }
  }

  // Usage example
  async uploadFiles(files: File[]) {
    const uploadPromises = files.map(file => 
      this.queueRequest(() => 
        this.http.post('/api/upload', { body: this.createFormData(file) })
      )
    );

    return Promise.all(uploadPromises);
  }

  private createFormData(file: File): FormData {
    const formData = new FormData();
    formData.append('file', file);
    return formData;
  }
}

Performance Optimization

Request Timeout Management

Implement sophisticated timeout handling:

export class TimeoutService {
  private http = resolve(IHttpClient);

  constructor() {
    this.setupTimeouts();
  }

  private setupTimeouts() {
    this.http.configure(config => config.withInterceptor({
      request: (request) => {
        // Add timeout based on request type
        const timeout = this.getTimeoutForRequest(request);
        
        if (timeout > 0) {
          const controller = new AbortController();
          const timeoutId = setTimeout(() => controller.abort(), timeout);
          
          // Store cleanup function
          (request as any).__timeoutId = timeoutId;
          
          return new Request(request, { signal: controller.signal });
        }
        
        return request;
      },
      
      response: (response, request) => {
        // Clear timeout on successful response
        const timeoutId = (request as any).__timeoutId;
        if (timeoutId) {
          clearTimeout(timeoutId);
        }
        return response;
      },
      
      responseError: (error, request) => {
        // Clear timeout on error
        const timeoutId = (request as any).__timeoutId;
        if (timeoutId) {
          clearTimeout(timeoutId);
        }
        
        // Convert AbortError to TimeoutError for clarity
        if (error.name === 'AbortError') {
          throw new Error(`Request timeout: ${request?.url}`);
        }
        
        throw error;
      }
    }));
  }

  private getTimeoutForRequest(request: Request): number {
    const url = new URL(request.url);
    
    // Different timeouts for different types of requests
    if (url.pathname.includes('/upload')) {
      return 300_000; // 5 minutes for uploads
    } else if (url.pathname.includes('/reports')) {
      return 120_000; // 2 minutes for reports
    } else if (request.method === 'GET') {
      return 30_000;  // 30 seconds for GET requests
    } else {
      return 60_000;  // 1 minute for other requests
    }
  }
}

Response Compression Handling

Handle compressed responses efficiently:

export class CompressionService {
  private http = resolve(IHttpClient);

  constructor() {
    this.setupCompression();
  }

  private setupCompression() {
    this.http.configure(config => config
      .withDefaults({
        headers: {
          'Accept-Encoding': 'gzip, deflate, br'
        }
      })
      .withInterceptor({
        response: (response) => {
          const encoding = response.headers.get('content-encoding');
          
          if (encoding) {
            console.log(`Response compressed with: ${encoding}`);
            
            // The browser automatically decompresses, but we can log it
            const originalSize = response.headers.get('content-length');
            if (originalSize) {
              console.log(`Compressed size: ${originalSize} bytes`);
            }
          }
          
          return response;
        }
      })
    );
  }
}

Testing and Debugging Support

Request/Response Logging

Comprehensive logging for development and debugging:

export class LoggingService {
  private http = resolve(IHttpClient);
  private isDevelopment = process.env.NODE_ENV === 'development';

  constructor() {
    if (this.isDevelopment) {
      this.setupDetailedLogging();
    } else {
      this.setupProductionLogging();
    }
  }

  private setupDetailedLogging() {
    this.http.configure(config => config.withInterceptor({
      request: (request) => {
        const requestId = this.generateRequestId();
        (request as any).__requestId = requestId;
        
        console.group(`🚀 Request ${requestId}`);
        console.log('Method:', request.method);
        console.log('URL:', request.url);
        console.log('Headers:', Object.fromEntries(request.headers.entries()));
        
        if (request.body) {
          this.logRequestBody(request);
        }
        console.groupEnd();
        
        return request;
      },
      
      response: async (response, request) => {
        const requestId = (request as any).__requestId;
        const responseClone = response.clone();
        
        console.group(`✅ Response ${requestId}`);
        console.log('Status:', response.status, response.statusText);
        console.log('Headers:', Object.fromEntries(response.headers.entries()));
        
        try {
          const body = await responseClone.text();
          if (body) {
            console.log('Body:', this.formatResponseBody(body, response));
          }
        } catch (error) {
          console.log('Body: (could not read)');
        }
        
        console.groupEnd();
        return response;
      },
      
      responseError: (error, request) => {
        const requestId = (request as any).__requestId;
        
        console.group(`❌ Error ${requestId}`);
        console.error('Error:', error);
        console.groupEnd();
        
        throw error;
      }
    }));
  }

  private setupProductionLogging() {
    this.http.configure(config => config.withInterceptor({
      responseError: (error, request) => {
        // Only log errors in production
        console.error('HTTP Error:', {
          url: request?.url,
          method: request?.method,
          error: error.message,
          timestamp: new Date().toISOString()
        });
        
        throw error;
      }
    }));
  }

  private logRequestBody(request: Request) {
    // Note: This is tricky because Request.body is a stream
    // In practice, you might want to log at a higher level
    console.log('Body: (stream - cannot log without consuming)');
  }

  private formatResponseBody(body: string, response: Response): any {
    const contentType = response.headers.get('content-type') || '';
    
    if (contentType.includes('application/json')) {
      try {
        return JSON.parse(body);
      } catch {
        return body;
      }
    }
    
    return body.length > 200 ? `${body.substring(0, 200)}...` : body;
  }

  private generateRequestId(): string {
    return Math.random().toString(36).substring(2, 8);
  }
}

These advanced patterns demonstrate the full power of the Aurelia Fetch Client for enterprise applications. The combination of interceptors, event monitoring, caching, and authentication patterns provides a robust foundation for complex HTTP client requirements.

Last updated

Was this helpful?