A thin fetch wrapper with zero runtime dependencies. 2.6 KB minified / 1.4 KB gzipped.
The package lives in the spechycom GitHub Packages registry. Add an .npmrc to the consuming project:
@spechycom:registry=https://npm.pkg.github.com
npm i @spechycom/requestFull guide: docs/usage.md — API reference, recipes (auth refresh, uploads, cancellation, test stubs), and an Axios migration table.
import req, { create, RequestError } from '@spechycom/request'
const api = create({
baseUrl: 'https://api.spechy.com/v1',
headers: { authorization: `Bearer ${token}` },
timeout: 10_000,
})
const me = await api.get<User>('/me', { params: { include: 'roles' } })
const ticket = await api.post<Ticket>('/tickets', { json: { subject: 'Hello' } })
const scoped = api.extend({ headers: { 'x-tenant': 'acme' } })- Responses are parsed by
content-type:jsonortext.HEAD,204andcontent-length: 0resolve tonull. - Non-2xx responses throw
RequestErrorwitherr.status,err.data(the parsed error body) anderr.response. - Pass
jsonand the body is serialized andcontent-typeis set. Leave it out andbodypasses through untouched, soFormData/Blobset their own boundary. - The whole
RequestInitsurface stays available (signal,credentials,cache,mode, ...). - Need headers or full control over status?
api.raw()returns theResponseand never throws.
await api.get('/report', { timeout: 3000 }) // rejects with err.name === 'TimeoutError'Counted per attempt. Pass your own signal and the two are combined via AbortSignal.any, so you never lose the ability to cancel — a caller abort surfaces as err.name === 'AbortError'.
Two extra attempts by default, only for GET PUT DELETE HEAD OPTIONS and 408 413 429 500 502 503 504 plus network errors and timeouts. POST and PATCH are never retried, and neither is a caller abort.
await api.get('/flaky', { retry: 5 })
await api.get('/once', { retry: 0 })Backoff is 300ms * 2^i capped at 10 s; on 429/503 a Retry-After header wins.
const api = create({
baseUrl,
beforeRequest: [
(ctx) => {
;(ctx.init.headers as Headers).set('x-request-id', crypto.randomUUID())
},
],
afterResponse: [
async (res, ctx) => {
if (res.status !== 401) return
await refreshToken()
return fetch(ctx.url, ctx.init) // continue with this response
},
],
})beforeRequestmay mutatectx.url/ctx.init; returning aResponseskipsfetchentirely (offline cache, test stubs).afterResponsereturning aResponsemakes the chain continue with it.- Hooks run on every attempt, and the retry decision looks at whatever the hooks produced last.
extend()concatenates hook arrays, merges headers key by key, and shallow-overrides everything else.
npm test # node tests -> build -> size gate -> real Chrome test
npm run test:node
npm run size # exits 1 if minified > 20 KB or gzipped > 7 KBRequires Node ≥ 20.3, Chrome ≥ 116, Safari ≥ 17.4 (for AbortSignal.any). ESM is primary, CJS ships as a second output.