@eugustavo/fergus
v0.1.0
Published
A modern, fully typesafe HTTP client built on top of fetch.
Maintainers
Readme
Fergus
"man of strength" — A modern, fully typesafe HTTP client built on top of
fetch.
Fergus is a next-generation HTTP client designed to be the Axios 2.0 you always wanted: familiar API, zero dependencies, native fetch, and a type system that actually works for you.
import { createFergus } from '@eugustavo/fergus'
const api = createFergus({ baseURL: 'https://api.example.com', timeout: 5000 })
const { data, error } = await api.get<User[]>('/users')Why Fergus?
| Feature | fetch | Axios | Fergus |
|---|---|---|---|
| Bundle size | 0KB (native) | ~13KB min+gzip | < 4KB min+gzip |
| Runtime dependencies | 0 | 2+ | 0 |
| TypeScript generics | Manual casting | Partial | Full end-to-end |
| { data, error } destructuring | ❌ | ❌ | ✅ |
| Axios-compatible response.data | ❌ | ✅ | ✅ |
| Built-in retry w/ exponential backoff | ❌ | ❌ | ✅ |
| Request deduplication | ❌ | ❌ | ✅ |
| Timeout via AbortController | Manual | ✅ | ✅ |
| Middleware pipeline (Koa-style) | ❌ | ❌ | ✅ |
| Interceptors (Axios-compatible) | ❌ | ✅ | ✅ |
| Shallow config merge (perf) | — | Deep merge | Shallow + headers only |
| Tree-shakeable | — | Partial | ✅ (sideEffects: false) |
| Native fetch (no XMLHttpRequest) | ✅ | ❌ | ✅ |
| Strongly typed errors | ❌ | Partial | ✅ |
| Request cancellation | Manual | ✅ | ✅ |
| Node.js support | 18+ | All | 18+ |
Installation
npm install @eugustavo/fergus
# or
pnpm add @eugustavo/fergus
# or
yarn add @eugustavo/fergusRequires Node.js >= 18 (native
fetchsupport)
Quick Start
import { createFergus } from '@eugustavo/fergus'
const api = createFergus({
baseURL: 'https://api.example.com',
timeout: 5000,
retry: 2,
})
const { data, error } = await api.get<User[]>('/users')
if (error) {
console.error(error.message)
return
}
console.log(data) // User[]Response API
Fergus returns a FergusResult<T> object with data, error, status, and headers:
const { data, error, status, headers } = await api.get<User[]>('/users')
if (error) {
console.error(error.message)
return
}
console.log(data) // User[]
console.log(status) // 200You can also access properties directly without destructuring:
const response = await api.get<User[]>('/users')
console.log(response.data) // User[]
console.log(response.status) // 200HTTP Methods
// GET
const { data: users } = await api.get<User[]>('/users')
const { data: user } = await api.get<User>('/users/1')
// POST
const { data: created } = await api.post<User>('/users', { name: 'Alice' })
// PUT
const { data: updated } = await api.put<User>('/users/1', { name: 'Alice Updated' })
// PATCH
const { data: patched } = await api.patch<User>('/users/1', { name: 'Alice' })
// DELETE
const { error } = await api.delete('/users/1')
// HEAD / OPTIONS
await api.head('/users')
await api.options('/users')Configuration
Instance Defaults
const api = createFergus({
baseURL: 'https://api.example.com',
timeout: 5000,
retry: 2,
dedupe: true,
headers: {
'Accept': 'application/json',
},
})
// Mutate defaults after creation (Axios-compatible)
api.defaults.timeout = 10_000
api.defaults.baseURL = 'https://v2.api.example.com'Per-Request Config
const { data } = await api.get('/users', {
params: { page: 1, limit: 20 },
timeout: 3000,
headers: { 'X-Custom': 'value' },
})Query Params
// Appended automatically — no manual string building
const { data } = await api.get('/users', {
params: { page: 1, limit: 20, active: true },
})
// → GET /users?page=1&limit=20&active=trueRetry
// Simple: number of attempts
const api = createFergus({ retry: 3 })
// Advanced: full config
const api = createFergus({
retry: {
attempts: 3,
baseDelay: 300, // ms before first retry
maxDelay: 5000, // cap on delay
retryOn: [408, 429, 500, 502, 503, 504],
retryOnNetworkError: true,
},
})Retry uses exponential backoff with ±10% jitter to avoid thundering herd:
attempt 0 → delay ~300ms
attempt 1 → delay ~600ms
attempt 2 → delay ~1200ms
...capped at maxDelayNever retries: canceled requests, timeouts, or 4xx client errors (unless explicitly listed in retryOn).
Request Deduplication
const api = createFergus({ dedupe: true })
// These two fire simultaneously — only ONE fetch is made
const [r1, r2] = await Promise.all([
api.get('/users'),
api.get('/users'),
])
// Both receive the same resultOnly deduplicates GET requests. POST/PUT/PATCH/DELETE are always executed independently.
Interceptors
// Request interceptor — add auth header
api.interceptors.request.use(async (config) => {
const token = await getToken()
return {
...config,
headers: { ...config.headers, Authorization: `Bearer ${token}` },
}
})
// Response interceptor — transform data
api.interceptors.response.use((result) => {
return { ...result, data: transform(result.data) }
})
// Eject an interceptor
const id = api.interceptors.request.use(myFn)
api.interceptors.request.eject(id)
// Clear all interceptors
api.interceptors.request.clear()Error Handling
const { data, error } = await api.get('/users')
if (error) {
if (error.isNetworkError) {
// No response received — DNS failure, connection refused, etc.
}
if (error.isTimeout) {
// Request exceeded the configured timeout
}
if (error.isCanceled) {
// Request was aborted via AbortSignal
}
if (error.status === 401) {
// HTTP error with status code
console.log(error.data) // Parsed response body
}
}Type Guards
import { FergusError } from '@eugustavo/fergus'
FergusError.isFergusError(err) // true for any FergusError
FergusError.isNetworkErr(err) // true for network errors
FergusError.isTimeoutErr(err) // true for timeout errors
FergusError.isCanceledErr(err) // true for canceled requestsRequest Cancellation
const controller = new AbortController()
const { data, error } = await api.get('/users', {
signal: controller.signal,
})
// Cancel from anywhere
controller.abort()
// Check if canceled
if (error?.isCanceled) {
console.log('Request was canceled')
}Response Types
// JSON (default)
const { data: json } = await api.get<MyType>('/data')
// Text
const { data: text } = await api.get<string>('/readme', { responseType: 'text' })
// Blob
const { data: blob } = await api.get<Blob>('/image.png', { responseType: 'blob' })
// ArrayBuffer
const { data: buffer } = await api.get<ArrayBuffer>('/file', { responseType: 'arrayBuffer' })
// FormData
const { data: form } = await api.get<FormData>('/form', { responseType: 'formData' })TypeScript
Fergus is built TypeScript-first. All generics flow through end-to-end:
interface User {
id: number
name: string
email: string
}
// data is User | null — no casting needed
const { data, error } = await api.get<User>('/users/1')
if (data) {
console.log(data.name) // ✅ fully typed
}
// Error data is also typed
const { error: err } = await api.post<User, { message: string }>('/users', payload)
if (err?.data) {
console.log(err.data.message) // ✅ typed error body
}Advanced: Custom Middleware
import { createFergus } from '@eugustavo/fergus'
import type { Middleware } from '@eugustavo/fergus'
const loggingMiddleware: Middleware = async (ctx, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
console.log(`${ctx.config.method} ${ctx.config.url} — ${duration}ms`)
}License
MIT
