@ma-dev/api-client
v0.1.5
Published
Shared HTTP client and token store for frontend projects.
Downloads
543
Maintainers
Readme
@ma-dev/api-client
Shared HTTP client infrastructure for frontend projects.
What's inside
| Export | Description |
| -------------------------- | ------------------------------------------------------------------- |
| createHttpClient(config) | Factory that returns a typed fetch wrapper with auto-auth injection |
| ApiError<T> | Generic structured error class thrown on non-2xx responses |
| tokenStore | Imperative singleton for storing the current bearer token |
| ApiResponse<T> | Generic response envelope matching the API contract |
| HttpClient | Inferred type of the object returned by createHttpClient |
| HttpClientConfig | Config interface for createHttpClient |
| RequestOptions | Per-request options (headers, params, signal, responseType) |
| RetryConfig | Retry strategy configuration |
| TokenRefreshConfig | Configuration for automatic token refresh |
| QueryParams | Query parameter map type |
| RequestInterceptor | Callback type for intercepting requests before dispatch |
| ResponseInterceptor | Callback type for response interceptors |
| ErrorInterceptor | Callback type for error interceptors |
Installation
# npm
npm install @ma-dev/api-client
# yarn
yarn add @ma-dev/api-client
# pnpm
pnpm add @ma-dev/api-client
# bun
bun add @ma-dev/api-clientQuick start
1. Create your project's HTTP client singleton
// src/lib/client.ts
import { createHttpClient, tokenStore } from "@ma-dev/api-client";
export const httpClient = createHttpClient({
baseUrl: import.meta.env.VITE_API_URL,
getToken: tokenStore.getToken,
timeoutMs: 10_000,
retry: { attempts: 2 },
});2. Sync the token store from your auth state
// After login
tokenStore.setToken(authData.token);
// After logout
tokenStore.setToken(null);3. Create domain services
// src/services/account.service.ts
import { httpClient } from "../lib/client";
import type { ApiResponse } from "@ma-dev/api-client";
interface LoginData {
token: string;
userId: string;
roles: string[];
}
type LoginResponse = ApiResponse<LoginData>;
export const accountService = {
login: (username: string, password: string) =>
httpClient.post<LoginResponse>("/account/login", { username, password }),
};4. Handle errors with Typed Body
import { ApiError } from "@ma-dev/api-client";
interface ValidationErrors {
code: string;
errors: string[];
}
try {
await accountService.login(username, password);
} catch (err) {
if (err instanceof ApiError) {
// Cast/infer error body structure
const errorBody = err.body as ValidationErrors;
console.error(`HTTP ${err.status}: ${err.message}`, errorBody.errors);
}
}Features
Query parameters
Pass a params object in RequestOptions instead of building query strings manually. Arrays are serialised as repeated keys, and Date objects are automatically serialized to ISO 8601 strings.
// GET /users?page=2&role=admin&role=editor&created=2026-06-20T18%3A00%3A00.000Z
const users = await httpClient.get<UserListResponse>("/users", {
params: {
page: 2,
role: ["admin", "editor"],
created: new Date("2026-06-20T18:00:00.000Z")
},
});null and undefined values are automatically omitted.
Request timeouts
Set a global deadline for every request:
const httpClient = createHttpClient({
baseUrl: "...",
timeoutMs: 8_000, // 8 s global timeout
});Or override per-request using a standard AbortSignal:
const result = await httpClient.get("/slow-endpoint", {
signal: AbortSignal.timeout(3_000),
});Retry with exponential back-off
const httpClient = createHttpClient({
baseUrl: "...",
retry: {
attempts: 3, // up to 3 retries (4 total attempts)
baseDelayMs: 300, // 300 ms → 600 ms → 1 200 ms
retryOn: [429, 500, 502, 503, 504], // default if omitted
},
});Retries are never triggered for AbortError (user/timeout cancellations).
Automatic Token Refresh (Self-Healing)
You can configure automatic token refresh handling on 401 Unauthorized responses. The refresh handler is deduplicated: if multiple concurrent requests encounter a 401, only one refresh operation is executed, and all pending requests wait for its outcome before retrying.
const httpClient = createHttpClient({
baseUrl: "...",
getToken: () => localStorage.getItem("access_token"),
tokenRefresh: {
refresh: async () => {
const response = await fetch("https://api.example.com/auth/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: localStorage.getItem("refresh_token") }),
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to refresh token");
const { access_token } = await response.json();
localStorage.setItem("access_token", access_token);
return access_token;
},
statusCodes: [401], // Defaults to [401]
}
});Interceptors
Request interceptors — called before a request is sent, allowing you to dynamically inspect and modify method, path, headers, or request options:
const httpClient = createHttpClient({
baseUrl: "...",
requestInterceptors: [
({ headers, options }) => {
headers.set("X-Correlation-Id", crypto.randomUUID());
},
],
});Response interceptors — called after every response, useful for logging or analytics:
const httpClient = createHttpClient({
baseUrl: "...",
responseInterceptors: [
(res, req) => {
console.log(`[${req.method}] ${req.path} → ${res.status}`);
},
],
});Error interceptors — called before ApiError is thrown. Return true to suppress the error:
import { useNavigate } from "react-router-dom";
const httpClient = createHttpClient({
baseUrl: "...",
errorInterceptors: [
(error) => {
if (error.status === 401) {
tokenStore.setToken(null);
window.location.href = "/login";
return true; // suppress the throw
}
},
],
});Binary and Custom Response Types
Choose how response bodies are parsed (defaults to automatic detection of JSON/Text):
// Blob
const avatarBlob = await httpClient.get<Blob>("/profile/avatar", {
responseType: "blob",
});
// ArrayBuffer
const buffer = await httpClient.get<ArrayBuffer>("/file", {
responseType: "arraybuffer",
});File uploads (multipart)
Supports uploading FormData with automatic Content-Type boundary detection. You can also specify custom methods (e.g., POST, PUT, PATCH):
const form = new FormData();
form.append("avatar", fileInput.files[0]);
const result = await httpClient.multipart<UploadResponse>(
"/profile/avatar",
form,
{ method: "PUT" } // Optional, defaults to POST
);Custom fetch Implementation
Useful for SSR environments (e.g. Next.js), mock environments, or proxy routing:
const httpClient = createHttpClient({
baseUrl: "...",
fetch: customFetchWrapper, // Custom fetch injection
});API reference
createHttpClient(config)
| Option | Type | Description |
| ---------------------- | ------------------------ | ---------------------------------------------------------- |
| baseUrl | string | Prepended to every path. No trailing slash. |
| getToken | TokenGetter | Returns bearer token. Support async Promise<string \| null> or sync string \| null. |
| defaultHeaders | Record<string, string> | Static headers merged into every request. |
| timeoutMs | number | Global request deadline in ms. Uses AbortSignal.timeout. |
| retry | RetryConfig | Retry strategy. Default: no retries. |
| tokenRefresh | TokenRefreshConfig | Deduplicated token refresh configuration. |
| requestInterceptors | RequestInterceptor[] | Called before request is dispatched. |
| responseInterceptors | ResponseInterceptor[] | Called after every response. |
| errorInterceptors | ErrorInterceptor[] | Called before an ApiError is thrown. |
| fetch | typeof fetch | Custom fetch implementation override. |
RequestOptions
| Option | Type | Description |
| -------------- | ------------- | ------------------------------------------------ |
| headers | HeadersInit | Extra headers for this request only. |
| params | QueryParams | Query-string parameters. Arrays → repeated keys. |
| signal | AbortSignal | Cancellation / per-request timeout. |
| responseType | "json" \| "text" \| "blob" \| "arraybuffer" | Forces response parsing method. |
RetryConfig
| Option | Type | Default | Description |
| ------------- | ---------- | -------------------------------- | ------------------------------------------ |
| attempts | number | — | Max retries after the initial failure. |
| baseDelayMs | number | 300 | Base delay in ms for exponential back-off. |
| retryOn | number[] | [408, 429, 500, 502, 503, 504] | Status codes that trigger a retry. |
Releasing and Changelog
This project uses standard-version to automatically generate CHANGELOG.md based on commit messages.
When you are ready to release a new version, simply run:
bun run releaseThis will automatically:
- Bump the version in
package.json. - Generate/update
CHANGELOG.mdwith all the new commits since the last release. - Commit the changes and create a Git tag for the new version.
