Middlewares

Intercept and wrap internal operations in CallApi

Middleware wraps and intercepts internal operations, giving you full control over execution flow. Currently CallApi supports fetch middleware for intercepting network requests at the lowest level, with more middleware types planned for future releases.

Middleware vs Hooks: Middleware controls execution (can return custom responses, short-circuit requests). Hooks observe events (return values ignored). Use middleware for control flow, hooks for side effects. See Hooks for details.

When a given middleware is defined at multiple levels (plugins, base config, per-request), they compose automatically in reverse order—the last middleware added wraps all previous ones.

Fetch Middleware

Fetch middleware wraps the underlying fetch implementation, giving you full control over when and how requests execute. Use it to cache responses, track progress, handle offline mode, add logging, or short-circuit requests.

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.

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 order, with each wrapping the previous one like onion layers. The last middleware added gets the outermost position:

Execution flow: Instance → Base → Plugins (last to first) → 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

Use fetch middleware when you need to:

  • Control whether fetch is called (short-circuit, return cached responses)
  • Replace the fetch implementation (use XHR, mock responses, alternative clients)
  • Transform the response (return a different Response object)
  • Wrap the network call (add retry logic, circuit breakers, request queuing)

Use hooks when you need to:

  • Observe lifecycle events without controlling flow
  • Log or track metrics
  • Modify request options by mutation
  • Handle errors after they occur
  • Trigger side effects (update UI, analytics)

Key distinction: Middleware wraps functions and controls what gets called and returned. Hooks receive context and can mutate objects, but their return values are ignored.

Edit on GitHub

Last updated on

On this page