@krislintigo/luro
v1.0.0
Published
Tiny chainable fetch client — build a request by chaining, await it to send it.
Maintainers
Readme
@krislintigo/luro
Tiny chainable HTTP client on top of native fetch. You describe a request by chaining, and
await sends it.
import luro from '@krislintigo/luro'
const user = await luro
.post('https://api.dev/users')
.json({ name: 'Kris' })
.headers({ authorization: `Bearer ${token}` })
.retry({ attempts: 3 })
.json<User>()- ~5 KB gzipped, zero dependencies, ESM only.
- Nothing but the request. No hooks, no interceptors, no schema validation, no plugin system — those belong around the client, not inside it.
- Immutable chain. Every method returns a new request, so a configured chain is a reusable template.
- Lazy. Nothing is sent until you
awaitthe chain or call a body reader.
Install
pnpm add @krislintigo/luroRequires Node 24+ or any runtime with fetch, AbortSignal.any and AbortSignal.timeout.
Sending a request
Start with a method, finish with await or a reader:
const response = await luro.get('https://api.dev/todos') // Response
const todos = await luro.get('https://api.dev/todos').json<Array<Todo>>() // parsed
const raw = await luro.get('https://api.dev/todos.csv').text() // stringluro.get / post / put / patch / delete / head / options all take (url, options?).
luro(url, options?) is the same thing with GET as the default method.
Readers: .json<T>(), .text(), .blob(), .arrayBuffer(), .bytes(), .formData() and
.response(). Awaiting the chain itself gives you the raw Response.
An empty body (204, 205, 304, or content-length: 0) resolves .json() to undefined
instead of throwing.
Chain reference
| Method | What it does |
| -------------------------- | ------------------------------------------------------------------ |
| .json(value) | Serializes the value and sets content-type: application/json |
| .form(value) | Sends FormData, URLSearchParams, or a plain object as form data |
| .body(value) | Sends a raw BodyInit untouched |
| .headers(headers) | Merges headers over the inherited ones |
| .query(params) | Merges search params into the URL |
| .timeout(ms) | Aborts each attempt after ms |
| .retry(count \| options) | Retries failed attempts |
| .signal(signal) | Attaches an AbortSignal |
| .throwHttpErrors(bool) | Resolves non-2xx responses instead of throwing |
| .method(method) | Overrides the HTTP method |
| .fetch(fn) | Swaps the fetch implementation |
| .init(init) | Escape hatch for the rest of RequestInit |
json does double duty on purpose: with an argument it sets the request body, without one it
reads the response.
const created = await luro.post(url).json({ title: 'luro' }).json<Todo>()
// ↑ sends ↑ readsClients
create gives you a client with defaults; extend derives a new one from it.
import { create } from '@krislintigo/luro'
const api = create({
baseUrl: 'https://api.dev/v1',
headers: { 'x-app': 'my-app' },
timeout: 10_000,
retry: 2,
})
const admin = api.extend({ baseUrl: 'https://api.dev/v1/admin' })
await api.get('todos') // https://api.dev/v1/todos
await admin.get('/todos') // https://api.dev/v1/admin/todosSlashes are normalized on both sides, so a base path is never swallowed. Absolute URLs skip
baseUrl entirely.
Every option is available both as a client default and as a chain method, and the chain always wins.
Headers
Layers merge, and null or undefined removes an inherited header:
await api.get('me').headers({ 'x-app': null })Headers can also be a function — sync or async, resolved on every request. That is all the authentication support this library needs:
const api = create({
baseUrl: 'https://api.dev',
headers: async () => ({ authorization: `Bearer ${await getFreshToken()}` }),
})Query
await api.get('todos').query({ page: 2, tag: ['work', 'home'], done: undefined })
// → /todos?page=2&tag=work&tag=homeKeys replace inherited ones instead of piling up; undefined and null values are dropped.
Strings and URLSearchParams work too.
Retry
Retries are opt-in — nothing is ever retried unless you ask for it.
await api.get('flaky').retry(3) // 3 extra attempts
await api.post('orders').json(order).retry({
attempts: 3,
delay: 300, // base delay, or (attempt: number) => number
factor: 2, // exponential backoff
maxDelay: 10_000,
jitter: true, // randomize between 50% and 100% of the delay
statuses: [408, 413, 429, 500, 502, 503, 504],
methods: ['GET', 'PUT', 'DELETE'], // defaults to every method
respectRetryAfter: true, // honour the `Retry-After` header
})Retryable by default: the statuses above, network errors, and timed-out attempts. Never retryable: aborts, and requests with a streaming body (it can only be sent once).
timeout applies per attempt, not to the whole chain.
Errors
import { HttpError, TimeoutError } from '@krislintigo/luro'
try {
await api.get('todos')
} catch (error) {
if (error instanceof HttpError) {
error.status // 404
error.response // untouched Response — the body is still readable
const problem = await error.response.json()
}
if (error instanceof TimeoutError) {
error.timeout // ms
}
}Non-2xx responses throw HttpError. To handle them as data instead:
const response = await api.get('todos').throwHttpErrors(false)Aborts reject with the original AbortError, so they stay distinguishable from timeouts.
Escape hatches
Anything this library does not model is one .init() away:
await api.get('me').init({ credentials: 'include', cache: 'no-store', keepalive: true })And fetch itself is swappable — for tests, for instrumentation, or for a custom agent:
const api = create({ fetch: async (url, init) => myFetch(url, init) })License
MIT
