@idevconn/api-client
v0.3.0
Published
Tiny JWT fetch wrapper with auto refresh, typed errors, and pluggable callbacks. Framework-agnostic. ~80 LOC. Zero runtime deps.
Readme
@idevconn/api-client
Tiny JWT fetch wrapper with automatic token refresh, typed errors, and pluggable callbacks. Framework-agnostic. Zero runtime dependencies.
Features
- Auto-injects
Authorization: Bearer <token>from your auth store. - Retries once on 401 via a configurable refresh endpoint.
- Surfaces failures as a typed
ApiErrorwithstatus,body, andmessage. - Wraps network failures (
fetchreject) asApiError(0, …)— never silent. - Optional global
onErrornotifier for crash reporting / default toasts. - Skips
Content-Typewhen sendingFormData(browser sets the boundary). - Returns
undefinedfor204 No Content.
Install
npm install @idevconn/api-clientQuick start
import { createApiClient, ApiError } from "@idevconn/api-client";
export const api = createApiClient({
baseUrl: import.meta.env.VITE_API_URL,
getAccessToken: () => authStore.getState().accessToken,
getRefreshToken: () => authStore.getState().refreshToken,
onTokenRefreshed: ({ accessToken, refreshToken }) =>
authStore.getState().setAuth({ accessToken, refreshToken }),
onUnauthorized: () => {
authStore.getState().logout();
window.location.href = "/login";
},
// Optional. Fires on every non-OK response and every network failure,
// BEFORE the error is thrown. Does NOT fire on the 401→onUnauthorized path.
onError: (err) => reportToCrashlytics(err, { status: err.status, body: err.body }),
});
// Usage
try {
const me = await api<{ id: string }>("/me");
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
showUpgradeModal();
}
}Configuration
| Field | Type | Required | Default | Notes |
| --------------------- | -------------------------------------------------------- | -------- | ------------------ | ---------------------------------------------------------------------------------------------------- |
| baseUrl | string | yes | — | Prepended to every request path. |
| getAccessToken | () => string \| null | yes | — | Read the current token from your store on each call (don't cache). |
| getRefreshToken | () => string \| null | yes | — | Same. |
| onTokenRefreshed | ({ accessToken, refreshToken }) => void | yes | — | Persist new tokens to your store. Called after a successful refresh. |
| onUnauthorized | () => void | yes | — | Called when refresh fails after a 401. Typically clears auth + redirects to login. |
| onError | (err: ApiError) => void | no | — | Global error notifier. Fires for non-OK responses + network failures (NOT the 401→unauthorized path). |
| refreshPath | string | no | /auth/refresh | Endpoint hit when refreshing. |
| refreshRequestField | string | no | refresh_token | Body field name sent to the refresh endpoint. |
| accessTokenField | string | no | access_token | Response field name carrying the new access token. |
| refreshTokenField | string | no | refresh_token | Response field name carrying the new refresh token. |
ApiError
class ApiError extends Error {
readonly status: number; // 0 for network failures
readonly body: unknown; // parsed JSON response (or null)
get isNetworkError(): boolean; // status === 0
}Status code, response body, and instanceof work in catch blocks and React Query onError handlers.
Error-handling semantics
| Scenario | onError fires? | onUnauthorized fires? | Throws? |
| ------------------------------------- | ---------------- | ----------------------- | --------------- |
| 2xx with body | no | no | no (returns body) |
| 204 | no | no | no (returns undefined) |
| 4xx / 5xx | yes | no | yes (ApiError) |
| 401, refresh succeeds, retry OK | no | no | no (returns body) |
| 401, refresh fails (or no refresh tok) | no | yes | yes (ApiError(401)) |
| Network failure / fetch reject | yes | no | yes (ApiError(0)) |
The onError callback runs before the throw — your try/catch and React Query onError still run after it.
License
Apache-2.0
