@firekid/hurl
v1.0.7
Published
Zero-dependency HTTP client for Node.js and edge runtimes. Built on fetch. The modern replacement for axios, request, got, node-fetch, and ky. Works on Cloudflare Workers, Vercel Edge, Deno, and Bun.
Maintainers
Keywords
Readme
hurl
A modern HTTP client for Node.js and edge runtimes. Zero dependencies. Full TypeScript support. Built to replace request and axios with a smaller, faster, and more capable alternative.
npm install @firekid/hurlGitHub
https://github.com/Firekid-is-him/hurl
Purpose
hurl solves the problems that request left behind when it was deprecated and that axios never fully addressed: no edge runtime support, a 35KB bundle, no built-in retry logic, no request deduplication, and no upload progress tracking. hurl ships all of these in under 10KB with zero runtime dependencies.
Core Concepts
Every method on hurl returns a HurlResponse<T> object. The response always includes the parsed data, status code, headers, a unique request ID, timing information, and a flag indicating whether the response was served from cache.
Defaults are set globally using hurl.defaults.set() and apply to every request made on that instance. Isolated instances with their own defaults can be created using hurl.create().
Interceptors run in the order they were registered and can be async. A request interceptor receives the URL and options before the request is sent. A response interceptor receives the full response object. An error interceptor receives a HurlError and can either return a modified error or resolve it into a response.
Installation
npm install @firekid/hurl
yarn add @firekid/hurl
pnpm add @firekid/hurlQuick Start
import hurl from '@firekid/hurl'
const res = await hurl.get('https://api.example.com/users')
res.data // parsed response body
res.status // 200
res.headers // Record<string, string>
res.requestId // unique ID for this request
res.timing // { start, end, duration }
res.fromCache // booleanHTTP Methods
hurl.get<T>(url, options?)
hurl.post<T>(url, body?, options?)
hurl.put<T>(url, body?, options?)
hurl.patch<T>(url, body?, options?)
hurl.delete<T>(url, options?)
hurl.head(url, options?)
hurl.options<T>(url, options?)
hurl.request<T>(url, options?)Global Defaults
hurl.defaults.set({
baseUrl: 'https://api.example.com',
headers: { 'x-api-version': '2' },
timeout: 10000,
retry: 3,
})
hurl.defaults.get()
hurl.defaults.reset()Request Options
All methods accept a HurlRequestOptions object.
type HurlRequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
headers?: Record<string, string>
body?: unknown
query?: Record<string, string | number | boolean>
timeout?: number
retry?: RetryConfig | number
auth?: AuthConfig
proxy?: ProxyConfig
cache?: CacheConfig
signal?: AbortSignal
followRedirects?: boolean
maxRedirects?: number
onUploadProgress?: ProgressCallback
onDownloadProgress?: ProgressCallback
stream?: boolean
throwOnError?: boolean
debug?: boolean
requestId?: string
deduplicate?: boolean
}Authentication
hurl.defaults.set({
auth: { type: 'bearer', token: 'my-token' }
})
hurl.defaults.set({
auth: { type: 'basic', username: 'admin', password: 'secret' }
})
hurl.defaults.set({
auth: { type: 'apikey', key: 'x-api-key', value: 'my-key' }
})
hurl.defaults.set({
auth: { type: 'apikey', key: 'token', value: 'my-key', in: 'query' }
})Retry
await hurl.get('/users', {
retry: 3
})
await hurl.get('/users', {
retry: {
count: 3,
delay: 300,
backoff: 'exponential',
on: [500, 502, 503],
}
})retry accepts a number (shorthand for count with exponential backoff) or a full RetryConfig object. Retries are not triggered for abort errors. If no on array is provided, retries fire on network errors, timeout errors, and any 5xx status.
Timeout and Abort
await hurl.get('/users', { timeout: 5000 })
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000)
await hurl.get('/users', { signal: controller.signal })Interceptors
const remove = hurl.interceptors.request.use((url, options) => {
return {
url,
options: {
...options,
headers: { ...options.headers, 'x-trace-id': crypto.randomUUID() },
},
}
})
remove()
hurl.interceptors.response.use((response) => {
console.log(response.status, response.timing.duration)
return response
})
hurl.interceptors.error.use((error) => {
if (error.status === 401) redirectToLogin()
return error
})
hurl.interceptors.request.clear()
hurl.interceptors.response.clear()
hurl.interceptors.error.clear()File Upload with Progress
const form = new FormData()
form.append('file', file)
await hurl.post('/upload', form, {
onUploadProgress: ({ loaded, total, percent }) => {
console.log(`${percent}%`)
}
})Download Progress
await hurl.get('/large-file', {
onDownloadProgress: ({ loaded, total, percent }) => {
console.log(`${percent}%`)
}
})Caching
Caching only applies to GET requests. Responses are stored in memory with a TTL in milliseconds.
await hurl.get('/users', {
cache: { ttl: 60000 }
})
await hurl.get('/users', {
cache: { ttl: 60000, key: 'all-users' }
})
await hurl.get('/users', {
cache: { ttl: 60000, bypass: true }
})import { clearCache, invalidateCache } from '@firekid/hurl'
// Clear the entire cache
clearCache()
// Invalidate a single entry by URL or custom key
invalidateCache('https://api.example.com/users')
invalidateCache('all-users') // if you used a custom cache keyRequest Deduplication
When deduplicate is true and the same GET URL is called multiple times simultaneously, only one network request is made.
const [a, b] = await Promise.all([
hurl.get('/users', { deduplicate: true }),
hurl.get('/users', { deduplicate: true }),
])Proxy
Native fetch does not support programmatic proxy configuration out of the box. Proxy support depends on your Node.js version:
Node.js 18 — install undici@6 (v7 dropped Node 18 support), use ProxyAgent:
// npm install undici@6
import { ProxyAgent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new ProxyAgent('http://proxy.example.com:8080'))Node.js 20 — undici is bundled with ProxyAgent support:
import { ProxyAgent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new ProxyAgent('http://proxy.example.com:8080'))Node.js 22.3+ — supports EnvHttpProxyAgent which reads HTTP_PROXY/HTTPS_PROXY env vars automatically:
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new EnvHttpProxyAgent())
// now set HTTP_PROXY=http://proxy.example.com:8080 in your envNode.js 24+ — native fetch respects env vars when NODE_USE_ENV_PROXY=1 is set:
NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://proxy.example.com:8080 node app.jsThe proxy option in HurlRequestOptions is reserved for a future release where this will be handled automatically.
Parallel Requests
const [users, posts] = await hurl.all([
hurl.get('/users'),
hurl.get('/posts'),
])Isolated Instances
const api = hurl.create({
baseUrl: 'https://api.example.com',
auth: { type: 'bearer', token: 'my-token' },
timeout: 5000,
retry: 3,
})
await api.get('/users')
const adminApi = api.extend({
headers: { 'x-role': 'admin' }
})create() produces a fully isolated instance — no shared defaults, interceptors, or state with the parent. extend() merges the provided defaults on top of the parent's and inherits all of the parent's interceptors.
Debug Mode
Logs the full request (method, url, headers, body, query, timeout, retry config) and response (status, timing, headers, data) to the console. Errors and retries are also logged.
await hurl.get('/users', { debug: true })Error Handling
hurl throws a HurlError on HTTP errors (4xx, 5xx), network failures, timeouts, aborts, and parse failures. It never resolves silently on bad status codes.
If you want to handle 4xx/5xx responses without a try/catch, set throwOnError: false — the response resolves normally and you can check res.status yourself.
const res = await hurl.get('/users', { throwOnError: false })
if (res.status === 404) {
console.log('not found')
}import hurl, { HurlError } from '@firekid/hurl'
try {
await hurl.get('/users')
} catch (err) {
if (err instanceof HurlError) {
err.type // 'HTTP_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT_ERROR' | 'ABORT_ERROR' | 'PARSE_ERROR'
err.status // 404
err.statusText // 'Not Found'
err.data // parsed error response body
err.headers // response headers
err.requestId // same ID as the request
err.retries // number of retries attempted
}
}TypeScript
type User = { id: number; name: string }
const res = await hurl.get<User[]>('/users')
res.data
const created = await hurl.post<User>('/users', { name: 'John' })
created.data.idResponse Shape
type HurlResponse<T> = {
data: T
status: number
statusText: string
headers: Record<string, string>
requestId: string
timing: {
start: number
end: number
duration: number
}
fromCache: boolean
}Environment Support
hurl runs anywhere the Fetch API is available.
- Node.js 18 and above
- Cloudflare Workers
- Vercel Edge Functions
- Deno
- Bun
Exports both ESM (import) and CommonJS (require).
API Reference
hurl.get(url, options?)
Sends a GET request. Returns Promise<HurlResponse<T>>.
hurl.post(url, body?, options?)
Sends a POST request. Body is auto-serialized to JSON if it is a plain object. Returns Promise<HurlResponse<T>>.
hurl.put(url, body?, options?)
Sends a PUT request. Returns Promise<HurlResponse<T>>.
hurl.patch(url, body?, options?)
Sends a PATCH request. Returns Promise<HurlResponse<T>>.
hurl.delete(url, options?)
Sends a DELETE request. Returns Promise<HurlResponse<T>>.
hurl.head(url, options?)
Sends a HEAD request. Returns Promise<HurlResponse<void>>.
hurl.options(url, options?)
Sends an OPTIONS request. Returns Promise<HurlResponse<T>>.
hurl.request(url, options?)
Sends a request with the method specified in options. Defaults to GET. Returns Promise<HurlResponse<T>>.
hurl.all(requests)
Runs an array of requests in parallel. Returns a promise that resolves when all requests complete. Equivalent to Promise.all.
hurl.create(defaults?)
Creates a new isolated instance with its own defaults, interceptors, and state. Does not inherit anything from the parent instance.
hurl.extend(defaults?)
Creates a new instance that inherits the current defaults, merges in the provided ones, and copies all parent interceptors (request, response, and error).
hurl.defaults.set(defaults)
Sets global defaults for the current instance. Merged into every request.
hurl.defaults.get()
Returns the current defaults object.
hurl.defaults.reset()
Resets defaults to the values provided when the instance was created.
hurl.interceptors.request.use(fn)
Registers a request interceptor. Returns a function that removes the interceptor when called.
hurl.interceptors.response.use(fn)
Registers a response interceptor. Returns a function that removes the interceptor when called.
hurl.interceptors.error.use(fn)
Registers an error interceptor. Returns a function that removes the interceptor when called.
clearCache()
Clears the entire in-memory response cache.
import { clearCache } from '@firekid/hurl'
clearCache()invalidateCache(key)
Removes a single entry from the in-memory cache by URL or custom cache key.
import { invalidateCache } from '@firekid/hurl'
invalidateCache('https://api.example.com/users')
invalidateCache('all-users') // if you used a custom cache keyLicense
MIT
