Middlewares

Intercept and wrap internal operations in CallApi

Middleware lets you wrap and intercept internal operations in CallApi. Currently, only fetch middleware is available for intercepting requests at the network layer, but the system is designed to support additional middleware types in the future.

When a given middleware is defined at multiple levels (plugins, base config, per-request), they compose automatically (i.e. each one wrapping the next in a chain).

Fetch Middleware

Fetch middleware wraps the underlying fetch implementation (native fetch or your custom implementation via customFetchImpl), giving you full control over when and how requests execute.

type FetchMiddleware = (context: RequestContext & { fetchImpl: FetchImpl }) => FetchImpl;

Each middleware receives a context object (containing the fetch implementation and request context) and returns a new fetch function. Use this to:

  • Cache responses
  • Track upload/download progress
  • Handle offline mode
  • Add logging or metrics
  • Short-circuit requests
  • Modify requests/responses

Using Fetch Middleware

Base Config

Apply fetch middleware to all requests by adding it to your client configuration:

api.ts
import {  } from "@zayne-labs/callapi";

const  = ({
	: "https://api.example.com",
	: () => async (, ) => {
		.("Request:", );
		const  = await .(, );
		.("Response:", .);
		return ;
	},
});

Per-Request

Add fetch middleware to individual requests for one-off modifications:

api.ts
import {  } from "@zayne-labs/callapi";

const { ,  } = await ("/users", {
	: () => async (, ) => {
		const  = .();
		const  = await .(, );
		.(`Took ${.() - }ms`);
		return ;
	},
});

In Plugins

Plugins can define fetch middleware to add reusable functionality. See Plugins for details.

plugins.ts
import {  } from "@zayne-labs/callapi/utils";

const  = ({
	: "logging",
	: "Logging Plugin",

	: {
		: () => async (, ) => {
			.("", ?. || "GET", );
			const  = await .(, );
			.("", ., );
			return ;
		},
	},
});

Fetch Middleware Composition

When multiple fetch middlewares are defined, they compose in reverse order, wrapping each other like onion layers:

Execution flow: Instance → Base → Plugins (reverse order) → customFetchImpl → fetch

example.ts
import { createFetchClient } from "@zayne-labs/callapi";

const client = createFetchClient({
	plugins: [cachingPlugin, loggingPlugin],
	fetchMiddleware: (ctx) => async (input, init) => {
		console.log("Base middleware");
		return ctx.fetchImpl(input, init);
	},
});

await client("/users", {
	fetchMiddleware: (ctx) => async (input, init) => {
		console.log("Instance middleware");
		return ctx.fetchImpl(input, init);
	},
});

// Execution order: Instance → Base → loggingPlugin → cachingPlugin → fetch

This reverse composition means the last middleware added gets the first chance to intercept the request.

Examples

Response Caching

caching-plugin.ts
import { , type  } from "@zayne-labs/callapi";
import {  } from "@zayne-labs/callapi/utils";
import {  } from "zod";

const  = .({
	: .().().(),
	: .(["cache-first", "no-cache"]).(),
});

export const  = () => {
	const  = new <string, { : Response; : number }>();

	return ({
		: "caching-plugin",
		: "Caching Plugin",

		: () => ,

		: ({
			,
		}: <{ : typeof  }>) => {
			const {  = 60_000,  = "cache-first" } = ;

			return {
				: () => async (, ) => {
					if ( === "no-cache") {
						return .(, );
					}

					const  =  instanceof  ? . : .();
					const  = .();

					const  = async () => {
						const  = await .(, );

						.(, { : .(), : .() });

						return ;
					};

					if (!) {
						.(`[Caching Plugin] Cache miss: ${}`);
						return ();
					}

					const  = .() - . > ;

					if () {
						.(`[Caching Plugin] Cache miss (expired): ${}`);
						.();

						return ();
					}

					.(`[Caching Plugin] Cache hit: ${}`);
					return ..();
				},
			};
		},
	});
};

const  = ({
	: "https://api.example.com",
	: [()],
	: "cache-first",
	: 2 * 60 * 1000, // 2 minutes
});

await ("/users");

await ("/users/:id", {
	: "no-cache", // Skip cache for this request
});

Offline Detection

offline.ts
import { createFetchClient } from "@zayne-labs/callapi";

const client = createFetchClient({
	fetchMiddleware: (ctx) => async (input, init) => {
		if (!navigator.onLine) {
			return Response.json(
				{ error: "No internet connection" },
				{
					status: 503,
					headers: { "Content-Type": "application/json" },
				}
			);
		}

		return ctx.fetchImpl(input, init);
	},
});

Request Timing

timing.ts
import { createFetchClient } from "@zayne-labs/callapi";

const client = createFetchClient({
	fetchMiddleware: (ctx) => async (input, init) => {
		const start = performance.now();

		try {
			const response = await ctx.fetchImpl(input, init);
			const duration = performance.now() - start;
			console.log(`${init?.method || "GET"} ${input} - ${duration.toFixed(2)}ms`);
			return response;
		} catch (error) {
			const duration = performance.now() - start;
			console.error(`${init?.method || "GET"} ${input} - Failed after ${duration.toFixed(2)}ms`);
			throw error;
		}
	},
});

Error Handling in Middleware

Middleware can catch and handle errors or transform them before they reach your application:

error-handling.ts
import { createFetchClient } from "@zayne-labs/callapi";

const client = createFetchClient({
	fetchMiddleware: (ctx) => async (input, init) => {
		try {
			const response = await ctx.fetchImpl(input, init);

			if (response.status === 503) {
				return Response.json(
					{ cached: true },
					{
						status: 200,
						headers: { "Content-Type": "application/json" },
					}
				);
			}

			return response;
		} catch (error) {
			if (error instanceof TypeError && error.message.includes("fetch")) {
				return Response.json(
					{ error: "Network error" },
					{
						status: 0,
						headers: { "Content-Type": "application/json" },
					}
				);
			}

			throw error;
		}
	},
});

Mock Responses for Testing

mock.ts
import { createFetchClient } from "@zayne-labs/callapi";

const mockData = {
	"/users/1": { id: 1, name: "John" },
	"/users/2": { id: 2, name: "Jane" },
};

const client = createFetchClient({
	fetchMiddleware: (ctx) => async (input, init) => {
		const url = input.toString();

		// Return mock data without calling fetch
		if (url in mockData) {
			return Response.json(mockData[url as keyof typeof mockData], {
				status: 200,
				headers: { "Content-Type": "application/json" },
			});
		}

		// Fall through to real fetch for unmocked URLs
		return ctx.fetchImpl(input, init);
	},
});

Middleware vs Hooks

The key difference: middleware controls execution flow, hooks observe and react.

Use fetch middleware when you need to:

  • Control whether fetch is called - Short-circuit requests, return cached responses without hitting the network
  • Replace the fetch implementation - Use XHR, mock responses, or alternative HTTP clients
  • Transform the response - Return a completely different Response object
  • Wrap the network call - Add retry logic, circuit breakers, or request queuing at the fetch level

Use hooks when you need to:

  • Observe lifecycle events - React to what's happening without controlling the flow
  • Log or track metrics - Record timing, errors, or success rates
  • Modify request options - Change headers, body, or other options before the request (by mutation)
  • Handle errors - Process errors after they occur
  • Side effects - Update UI, trigger analytics, or perform other actions

Key distinction: Hooks receive context and can mutate objects, but their return values are ignored. Middleware wraps functions and controls what gets called and what gets returned. If you need to prevent a fetch call or return a custom response, use middleware. If you need to observe or react to events, use hooks.

Edit on GitHub

Last updated on

On this page