Skip to content

Middleware

Middleware lets you intercept and modify requests and responses. Use it for logging, authentication, error handling, data transformation, and more.

Built-in Middleware

FetchClient provides built-in middleware factories for common patterns:

ts
import fc from "@foundatiofx/fetchclient";

// Retry failed requests with exponential backoff
fc.use(fc.middleware.retry({ limit: 3 }));

// Rate limiting
fc.use(fc.middleware.rateLimit({ maxRequests: 100, windowSeconds: 60 }));
fc.use(
  fc.middleware.perDomainRateLimit({ maxRequests: 50, windowSeconds: 60 }),
);

// Circuit breaker for fault tolerance
fc.use(fc.middleware.circuitBreaker({ failureThreshold: 5 }));
fc.use(fc.middleware.perDomainCircuitBreaker({ failureThreshold: 3 }));

Available Middleware Factories

  • fc.middleware.retry(options) - Retry failed requests with exponential backoff and jitter
  • fc.middleware.rateLimit(options) - Global rate limiting
  • fc.middleware.perDomainRateLimit(options) - Per-domain rate limiting
  • fc.middleware.circuitBreaker(options) - Global circuit breaker
  • fc.middleware.perDomainCircuitBreaker(options) - Per-domain circuit breaker

How Middleware Works

Middleware functions receive a context object and a next function. Call next() to continue to the next middleware (or the actual fetch). Code before next() runs before the request; code after runs after the response.

ts
async function myMiddleware(ctx, next) {
  // Before request
  console.log("Starting:", ctx.request.url);

  await next(); // Execute request

  // After response
  console.log("Completed:", ctx.response?.status);
}

Adding Custom Middleware

Using Default Export

The simplest way to add custom middleware:

ts
import fc from "@foundatiofx/fetchclient";

fc.use(async (ctx, next) => {
  console.log("Request:", ctx.request.url);
  await next();
  console.log("Response:", ctx.response?.status);
});

Global Middleware

Applies to all FetchClient instances (equivalent to fc.use()):

ts
import { useMiddleware } from "@foundatiofx/fetchclient";

useMiddleware(async (ctx, next) => {
  console.log("Request:", ctx.request.url);
  await next();
  console.log("Response:", ctx.response?.status);
});

Provider Middleware

Applies to clients from a specific provider:

ts
import { FetchClientProvider } from "@foundatiofx/fetchclient";

const provider = new FetchClientProvider();

provider.useMiddleware(async (ctx, next) => {
  // Add custom header
  ctx.request.headers.set("X-Custom-Header", "value");
  await next();
});

const client = provider.getFetchClient();

Client Middleware

Applies to a single client:

ts
import { FetchClient } from "@foundatiofx/fetchclient";

const client = new FetchClient({
  middleware: [
    async (ctx, next) => {
      console.log("Client-specific middleware");
      await next();
    },
  ],
});

Middleware Context

The context object provides access to the request and response:

ts
interface FetchClientContext {
  request: Request; // The outgoing request
  response?: Response; // The response (after next())
  data?: unknown; // Parsed response data (JSON)
  options: RequestOptions; // Request options
}

Common Middleware Patterns

Logging

ts
provider.useMiddleware(async (ctx, next) => {
  const start = Date.now();
  console.log(`→ ${ctx.request.method} ${ctx.request.url}`);

  await next();

  const duration = Date.now() - start;
  console.log(`← ${ctx.response?.status} (${duration}ms)`);
});

Authentication

ts
provider.useMiddleware(async (ctx, next) => {
  const token = localStorage.getItem("token");
  if (token) {
    ctx.request.headers.set("Authorization", `Bearer ${token}`);
  }
  await next();
});

Or use the built-in access token function:

ts
import { setAccessTokenFunc } from "@foundatiofx/fetchclient";

setAccessTokenFunc(() => localStorage.getItem("token"));

Request/Response Transformation

ts
// Add timestamp to all POST requests
provider.useMiddleware(async (ctx, next) => {
  if (ctx.request.method === "POST") {
    const body = await ctx.request.json();
    body.timestamp = Date.now();
    ctx.request = new Request(ctx.request, {
      body: JSON.stringify(body),
    });
  }
  await next();
});

Error Handling

ts
provider.useMiddleware(async (ctx, next) => {
  await next();

  if (ctx.response && !ctx.response.ok) {
    // Log errors
    console.error(`API Error: ${ctx.response.status} ${ctx.request.url}`);

    // Could transform the response or add metadata
  }
});

Retry Logic

Use the built-in retry middleware for automatic retries with exponential backoff:

ts
import fc from "@foundatiofx/fetchclient";

fc.use(fc.middleware.retry({
  limit: 3, // Max retry attempts
  methods: ["GET", "HEAD"], // Only retry idempotent methods
  statusCodes: [408, 429, 500, 502, 503, 504], // Status codes to retry
  maxRetryAfter: 60, // Max seconds to wait for Retry-After header
}));

Or implement custom retry logic:

ts
provider.useMiddleware(async (ctx, next) => {
  const maxRetries = 3;
  let lastError: Error | undefined;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      await next();

      // Success or client error - don't retry
      if (ctx.response && ctx.response.status < 500) {
        return;
      }
    } catch (error) {
      lastError = error as Error;
    }

    // Wait before retry (exponential backoff)
    if (attempt < maxRetries - 1) {
      await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
    }
  }

  if (lastError) throw lastError;
});

Response Validation with Zod

ts
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

provider.useMiddleware(async (ctx, next) => {
  await next();

  // Validate response data
  if (ctx.data && ctx.request.url.includes("/users/")) {
    const result = UserSchema.safeParse(ctx.data);
    if (!result.success) {
      console.error("Invalid user data:", result.error);
      // Optionally throw or modify response
    }
  }
});

Analytics/Metrics

ts
provider.useMiddleware(async (ctx, next) => {
  const start = performance.now();

  try {
    await next();
  } finally {
    const duration = performance.now() - start;

    // Send to analytics
    analytics.track("api_request", {
      url: ctx.request.url,
      method: ctx.request.method,
      status: ctx.response?.status,
      duration,
    });
  }
});

Middleware Execution Order

Middleware executes in the order it's added:

ts
provider.useMiddleware(async (ctx, next) => {
  console.log("1. First middleware - before");
  await next();
  console.log("6. First middleware - after");
});

provider.useMiddleware(async (ctx, next) => {
  console.log("2. Second middleware - before");
  await next();
  console.log("5. Second middleware - after");
});

provider.useMiddleware(async (ctx, next) => {
  console.log("3. Third middleware - before");
  await next();
  console.log("4. Third middleware - after");
});

// Output:
// 1. First middleware - before
// 2. Second middleware - before
// 3. Third middleware - before
// (fetch happens here)
// 4. Third middleware - after
// 5. Second middleware - after
// 6. First middleware - after

Accessing Response Data

After next(), you can access the parsed response data:

ts
provider.useMiddleware(async (ctx, next) => {
  await next();

  // ctx.data contains the parsed JSON response
  if (ctx.data) {
    console.log("Response data:", ctx.data);
  }

  // ctx.response is the raw Response object
  console.log("Status:", ctx.response?.status);
  console.log("Headers:", ctx.response?.headers);
});

Short-Circuiting

You can return early without calling next() to skip the actual request:

ts
provider.useMiddleware(async (ctx, next) => {
  // Return cached data for specific URLs
  if (ctx.request.url.includes("/static/")) {
    ctx.response = new Response(JSON.stringify({ cached: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
    return; // Don't call next()
  }

  await next();
});

Practical Example: Complete Setup

Simple Setup with Default Export

ts
import fc from "@foundatiofx/fetchclient";
import { setAccessTokenFunc, setBaseUrl } from "@foundatiofx/fetchclient";

setBaseUrl("https://api.example.com");
setAccessTokenFunc(() => getAuthToken());

// Built-in middleware
fc.use(fc.middleware.retry({ limit: 3 }));
fc.use(
  fc.middleware.perDomainRateLimit({ maxRequests: 100, windowSeconds: 60 }),
);
fc.use(fc.middleware.circuitBreaker({ failureThreshold: 5 }));

// Custom logging
fc.use(async (ctx, next) => {
  const start = Date.now();
  console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
  await next();
  console.log(`← ${ctx.response?.status} (${Date.now() - start}ms)`);
});

// Use anywhere
const { data: users } = await fc.getJSON<User[]>("/users");

Using Provider

ts
import { FetchClientProvider } from "@foundatiofx/fetchclient";

const provider = new FetchClientProvider();
provider.setBaseUrl("https://api.example.com");

// 1. Logging
provider.useMiddleware(async (ctx, next) => {
  const start = Date.now();
  console.log(`→ ${ctx.request.method} ${ctx.request.url}`);

  await next();

  console.log(`← ${ctx.response?.status} (${Date.now() - start}ms)`);
});

// 2. Authentication
provider.useMiddleware(async (ctx, next) => {
  const token = getAuthToken();
  if (token) {
    ctx.request.headers.set("Authorization", `Bearer ${token}`);
  }
  await next();

  // Handle 401
  if (ctx.response?.status === 401) {
    clearAuthToken();
    window.location.href = "/login";
  }
});

// 3. Error tracking
provider.useMiddleware(async (ctx, next) => {
  try {
    await next();

    if (ctx.response && ctx.response.status >= 500) {
      errorTracker.captureMessage(`API 5xx: ${ctx.request.url}`);
    }
  } catch (error) {
    errorTracker.captureException(error);
    throw error;
  }
});

// Rate limiting and circuit breaker
provider.usePerDomainRateLimit({ maxRequests: 100, windowSeconds: 60 });
provider.useCircuitBreaker({ failureThreshold: 5 });

const client = provider.getFetchClient();

Released under the MIT License.