laihui
v0.0.1
Published
A composable, middleware-driven HTTP client built on the Fetch API
Readme
laihui
A composable, middleware-driven HTTP client built on the Fetch API.
import { createFetch } from 'laihui'
const api = createFetch('https://api.example.com')
// Middleware pipeline (onion model)
api.use(async (ctx, next) => {
console.time(ctx.request.url)
await next()
console.timeEnd(ctx.request.url)
})
// GET with type inference
const user = await api.get('/users/1').json<User>()
// POST with JSON body
const post = await api.post('/posts', {
body: { title: 'Hello', content: 'World' },
}).json()
// Extend instance
const admin = api.extend({
headers: { Authorization: 'Bearer token' },
})Features
- Middleware pipeline — Koa-style onion model, not flat hooks
- Zero dependencies in core
- Universal — Browser, Node 20+, Bun, Deno, Cloudflare Workers, Edge Runtime
- TypeScript — Strict mode, generics on
.json<T>() - Tree-shakable — ESM only, optional features via sub-path imports
- Immutable —
extend()never mutates
Install
pnpm add laihuiUsage
Basic
const api = createFetch('https://api.example.com')
const data = await api.get('/users').json<User>()Headers
const api = createFetch('https://api.example.com').extend({
headers: { Authorization: 'Bearer token' },
})
await api.get('/users', {
headers: { 'X-Request-ID': 'abc' },
})Methods
api.get(url, options?)
api.post(url, options?)
api.put(url, options?)
api.patch(url, options?)
api.delete(url, options?)
api.head(url, options?)
api.options(url, options?)
api.trace(url, options?)Response
const res = api.get('/users')
await res // → Response
await res.json<T>() // → T
await res.text() // → string
await res.blob() // → Blob
await res.bytes() // → Uint8Array
await res.stream() // → ReadableStreamMiddleware
Use .use(fn) where fn is (ctx, next) => Promise<void>:
api.use(async (ctx, next) => {
console.log('→', ctx.request.method, ctx.request.url)
await next()
console.log('←', ctx.response?.status)
})Share state between middlewares via ctx.state:
api.use(async (ctx, next) => {
ctx.state.user = await getUser()
await next()
})Built-in Middleware
Timeout
import { timeout } from 'laihui/middleware/timeout'
api.use(timeout({ timeout: 5000 }))Retry
import { retry } from 'laihui/middleware/retry'
api.use(retry({
limit: 3,
delay: 1000,
backoff: 'exponential',
jitter: true,
statusCodes: [408, 413, 429, 500, 502, 503, 504],
}))Deduplication
import { dedupe } from 'laihui/middleware/dedupe'
api.use(dedupe())Auth / Token Refresh
import { auth } from 'laihui/middleware/auth-refresh'
api.use(auth({
getToken: () => localStorage.getItem('token'),
refreshToken: async () => {
const newToken = await fetch('/refresh').then(r => r.text())
localStorage.setItem('token', newToken)
return newToken
},
}))Error Handling
import { HTTPError, TimeoutError, RetryError } from 'laihui'
try {
await api.get('/users').json()
} catch (error) {
if (error instanceof HTTPError) {
console.error(error.response.status, error.response.statusText)
} else if (error instanceof TimeoutError) {
console.error('Request timed out')
} else if (error instanceof RetryError) {
console.error('Retry failed after', error.attempts, 'attempts')
}
}Extend
const api = createFetch('https://api.example.com')
const admin = api.extend({
headers: { Authorization: 'Bearer admin' },
})
// api is not mutated — immutablePagination
import 'laihui/patch/pagination'
interface Page {
id: number
title: string
}
const api = createFetch('https://api.example.com')
const pages = api.pagination<Page>('/api/pages', {
mode: 'page', // 'page' | 'cursor' | 'offset'
size: 20,
params: { status: 'published' },
})
const p1 = await pages.next()
// { items: Page[], total: 100, next: true, prev: false, page: 1, size: 20 }
const p2 = await pages.next() // page 2
const back = await pages.prev() // page 1
const jump = await pages.index(5) // page 5 (page mode only)
const again = await pages.reload()Polling
import 'laihui/patch/poll'
const poll = api.poll<{ status: string }>('/jobs/1', {
interval: 2000,
condition: (data) => data.status === 'completed',
})
poll.on('data', (data) => console.log(data))
poll.on('done', (result) => console.log('Finished', result))
poll.on('error', (err) => console.error(err))
// Manual fetch
const result = await poll.next()
// Auto-polling
poll.start()
poll.stop()Download
import 'laihui/patch/download'
const task = api.download('/files/report.pdf', {
onProgress: ({ transferred, total, percent }) => {
console.log(`${Math.round(percent * 100)}%`)
},
})
// Awaitable — resolves to Uint8Array (Node) or Blob (browser)
const data = await task
// Or with lifecycle controls
task.on('progress', (p) => console.log(p.percent))
task.on('done', (data) => console.log('done', data))
task.on('error', (err) => console.error(err))
task.pause() // aborts current request, keeps downloaded bytes
task.resume() // resumes via Range / If-Range from where it stopped
task.abort() // cancelsDownloads run through the middleware pipeline. Pause/resume use HTTP
Range requests (with If-Range validation); if the server ignores
Range and returns 200, the download restarts from scratch.
Upload
import 'laihui/patch/upload'
const task = api.upload('/files', file, {
onProgress: ({ transferred, total, percent }) => {
console.log(`${Math.round(percent * 100)}%`)
},
})
const response = await task // resolves to Response
task.on('progress', (p) => console.log(p.percent))
task.abort()Transport selection (transport: 'auto' | 'fetch' | 'xhr', default 'auto'):
'auto'— feature-detects support for request streams. If supported, uploads with progress viafetch+duplex: 'half'. If not supported and an XHR fallback is provided (browser only), uses XHR. Otherwise it warns and degrades to a progress-less upload.'fetch'— forcefetchstreaming.'xhr'— force XHR (requires the optional fallback transport).
The XHR transport is an opt-in module to keep the core pure-fetch and
tree-shakable:
import { xhrTransport } from 'laihui/patch/upload/xhr'
api.upload('/files', file, {
transport: 'auto',
fallback: xhrTransport, // enables progress in HTTP/1.1 / Safari
onProgress: (p) => console.log(p.percent),
})Resource (CRUD)
import 'laihui/patch/resource'
const users = api.resource('/users')
// GET /users
const list = await users.list().json()
// GET /users/42
const user = await users.get(42).json()
// POST /users
const created = await users.create({ name: 'Alice' }).json()
// PUT /users/7
const updated = await users.update(7, { name: 'Bob' }).json()
// DELETE /users/99
await users.remove(99)API
createFetch(baseURL?: string)
Returns a FetchInstance.
FetchInstance
| Member | Description |
|--------|-------------|
| .get(url, options?) | GET request |
| .post(url, options?) | POST request |
| .put(url, options?) | PUT request |
| .patch(url, options?) | PATCH request |
| .delete(url, options?) | DELETE request |
| .head(url, options?) | HEAD request |
| .options(url, options?) | OPTIONS request |
| .trace(url, options?) | TRACE request |
| .use(middleware) | Register a middleware function |
| .extend(options) | Create a new immutable instance |
| .pagination(url, config?, options?) | Pagination helper (requires import 'laihui/patch/pagination') |
| .poll(url, config?) | Polling helper (requires import 'laihui/patch/poll') |
| .resource(url, config?) | CRUD helper (requires import 'laihui/patch/resource') |
| .download(url, config?) | Download task helper (requires import 'laihui/patch/download') |
| .upload(url, file, config?) | Upload task helper (requires import 'laihui/patch/upload') |
FetchOptions
Extends RequestInit (omitting signal, body).
| Option | Type | Description |
|--------|------|-------------|
| baseURL | string | Base URL for relative URL resolution |
| headers | HeadersInit | Default request headers |
| json | boolean | Default true. When true, body is treated as a JsonValue, automatically JSON.stringify-ed and sets Content-Type: application/json. When false, body is treated as raw BodyInit \| null |
| body | JsonValue \| BodyInit \| null | Request body. Type is narrowed based on json: when json is true (default), expected as JsonValue; when json: false, expected as BodyInit \| null |
| signal | AbortSignal \| AbortSignal[] | Single or multiple abort signals (merged automatically) |
| params | URLSearchParamsInit (string \| URLSearchParams \| Iterable<[string, string \| number \| boolean]> \| Record<string, string \| number \| boolean>) | Query parameters appended to URL (serialized via URLSearchParams; a raw string is used as-is). For complex serialization, pass the final value yourself, e.g. params: qs.stringify(obj) / new URLSearchParams(obj) / new Map([...]) |
FetchResponse
Thenable (awaitable to Response directly) with typed helpers:
interface FetchResponse extends PromiseLike<Response> {
json: <T = unknown>() => Promise<T>
text: () => Promise<string>
blob: () => Promise<Blob>
stream: () => Promise<ReadableStream<Uint8Array>>
bytes: () => Promise<Uint8Array>
clone: () => Promise<Response>
}The response body can only be consumed once (same as the native Fetch API). To read it multiple times, clone before reading:
const res = await api.get('/users').clone()
const a = await res.json()
const b = await res.clone().text()Error Classes
| Class | Extends | Description |
|-------|---------|-------------|
| FetchError | Error | Base error with .request and .cause |
| HTTPError | FetchError | Non-ok response, has .response |
| AbortError | FetchError | Request was aborted |
| TimeoutError | AbortError | Request timed out |
| RetryError | FetchError | Retries exhausted, has .attempts |
| NetworkError | FetchError | Network failure, has optional .code |
License
MIT
