Example: production HTTP client
This example covers the code that usually ends up scattered across a large request.ts: credentials, auth headers, locale, business envelopes, endpoint exceptions, query functions, downloads, errors, and gradual axios migration.
Requirements
The backend uses an existing response type and status dictionary:
export const ApiCode = {
Ok: 10_000,
SessionExpired: 14_001,
Validation: 24_001,
} as const;
interface ApiEnvelope<T> {
status: (typeof ApiCode)[keyof typeof ApiCode];
message: string;
payload: T;
}Downloads return blobs. One legacy endpoint still expects a form string.
1. Define reusable middleware
// src/api/middleware.ts
import { auth, BizError, createMiddleware, legacyStringBody } from "@envoijs/http";
export const requestMiddleware = createMiddleware({
onRequest: [
auth(() => sessionStorage.getItem("access_token")),
(ctx) => {
ctx.request.headers["Accept-Language"] = getLocale();
ctx.request.headers["X-Trace-Id"] = crypto.randomUUID();
},
legacyStringBody(),
],
});
export const responseMiddleware = createMiddleware({
onResponseError: (ctx) => {
if (!(ctx.error instanceof BizError)) return;
if (ctx.error.kind === "unauthorized") {
clearSessionAndRedirect();
return;
}
if (ctx.request.meta.silent !== true) showApiError(ctx.error.msg);
},
});2. Create the client
// src/api/http.ts
import {
axiosAdapter,
createAxiosInstance,
createHttpFactory,
mergeMiddleware,
} from "@envoijs/http";
import { ApiCode } from "./contracts";
import { requestMiddleware, responseMiddleware } from "./middleware";
const instance = createAxiosInstance({
withCredentials: true,
xsrfCookieName: "XSRF-TOKEN",
});
export const createProjectHttp = createHttpFactory({
adapter: axiosAdapter(instance),
defaults: {
baseURL: "/api",
timeout: 20_000,
headers: {
Accept: "application/json",
},
},
envelope: {
code: "status",
msg: "message",
data: "payload",
ok: (code) => code === ApiCode.Ok,
unauthorized: (code) => code === ApiCode.SessionExpired,
warning: (code) => code === ApiCode.Validation,
},
hooks: mergeMiddleware(requestMiddleware, responseMiddleware),
});
export const http = createProjectHttp();3. Keep API functions small
// src/api/profile.ts
import { http } from "./http";
export interface Profile {
id: number;
displayName: string;
roles: string[];
}
export function getProfile(): Promise<Profile> {
return http.get<Profile>("/profile");
}// src/api/orders.ts
interface Order {
id: string;
status: "draft" | "paid" | "shipped";
total: number;
}
interface Page<T> {
rows: T[];
total: number;
}
export function listOrders(query: {
page: number;
pageSize: number;
status?: Order["status"];
}): Promise<Page<Order>> {
return http.get<Page<Order>>("/orders", { query });
}4. Hand the function to any consumer
// Plain application code
const profile = await getProfile();
// Query library
const profileQuery = {
queryKey: ["profile"],
queryFn: getProfile,
};
// Background request without global toast
await http.get("/session/check", { silent: true });The API function contract stays Promise<T> in every case.
5. Handle a one-endpoint exception locally
const reportMiddleware = createMiddleware({
onRequest: (ctx) => {
ctx.request.headers["X-Report-Version"] = "1";
},
onResponse: (ctx) => {
ctx.response.body = normalizeLegacyReport(ctx.response.body);
},
});
export function getLegacyReport(): Promise<Report> {
return http.get<Report>("/legacy/report", {
hooks: reportMiddleware,
});
}The exception remains visible beside the endpoint. Global hooks stay free of URL branches.
6. Download a file
export async function downloadInvoice(id: string): Promise<void> {
const response = await http.raw<Blob>(`/invoices/${id}/file`, {
responseType: "blob",
});
saveAs(response.body, `invoice-${id}.pdf`);
}HTTP 4xx/5xx still reject. Use ignoreResponseError: true only when the endpoint contract explicitly returns useful failure payloads.
7. Migrate a legacy form endpoint
New code should use URLSearchParams:
export function copyOrder(input: { orderId: string }): Promise<void> {
return http.post("/orders/copy", new URLSearchParams({ orderId: input.orderId }));
}An unchanged axios 0.x-style string can opt into the compatibility hook configured above:
return http.post("/legacy/copy", "orderId=ord_42");legacyStringBody() sets an explicit form Content-Type for non-JSON strings.
8. Migrate incrementally
The old request module and envoi client can coexist:
src/api/http.ts new envoi client
src/api/profile.ts migrated
src/api/orders.ts migrated
src/utils/request.ts remaining legacy endpointsMove one API module at a time. Delete the old interceptor file after the last caller has moved and behavior has been verified.
Suggested file layout
src/api/
├── http.ts
├── profile.ts
├── orders.ts
└── invoices.tsThe HTTP client owns transport and response policy. Each API module owns endpoint paths and business types. Components, stores, and query libraries consume the resulting promises.