A dependency-free, fetch-native HTTP client for modern JavaScript and TypeScript runtimes.
npm install @gavoryn/clearfetchThis README describes the 2.0.0 source. For changes from 1.x, see the migration guide. Check npm and GitHub Releases for publication status.
Use clearfetch when you want a thin layer over native fetch, not a separate transport abstraction.
Choose it when you want:
- reusable client defaults for
baseURL, headers, timeout, retries, and hooks - JSON request/response convenience without runtime dependencies
- predictable typed errors instead of repeating the same
fetchboilerplate - a small surface area that is easy to audit
clearfetch is intentionally narrow. It is probably not the right client if you need:
- upload or download progress APIs
- interceptor-style response rewriting or a middleware ecosystem
- legacy CommonJS or old-runtime support
- automatic caching, cookie jars, XSRF helpers, or transport adapters
- a broader, older, more feature-rich abstraction like axios
Hooks are intentionally not axios-style interceptors.
import { request } from '@gavoryn/clearfetch'
const user = await request<{ id: string; name: string }>(
'https://api.example.com/users/123',
)import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
headers: {
Accept: 'application/json',
},
timeout: 5_000,
})
const user = await api.get<{ id: string; name: string }>('/users/123')import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
})
const users = await api.get('/users', {
query: {
active: true,
tag: ['admin', 'editor'],
},
})
const ordered = await api.get('/users', {
query: new URLSearchParams('tag=admin&page=1&tag=editor'),
})Use an object for ordinary query parameters. Use native URLSearchParams when duplicate-key ordering matters.
import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
})
const created = await api.post<{ id: string }>('/users', {
json: {
name: 'Ada Lovelace',
role: 'admin',
},
})If json is provided, clearfetch:
- serializes the value with
JSON.stringify() - sets
Content-Type: application/jsonif it is not already present - rejects the request with
ConfigErrorifbodyis also provided - rejects values when
JSON.stringify()returnsundefinedor throwsTypeErrorwithConfigError; other caller-owned exceptions encountered during serialization, including fromtoJSON()or property access, propagate as-is
Use body directly only when you want to send a raw payload such as FormData, URLSearchParams, or pre-serialized text.
import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
})
const form = new FormData()
form.set('avatar', fileInput.files[0])
await api.post('/profile/avatar', {
body: form,
})import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
})
const authed = api.extend({
headers: {
Authorization: 'Bearer token',
},
})
const profile = await authed.get('/me')Request-level options override client defaults. Request headers replace matching client header values, while client hooks run before request hooks and each hook list retains its definition order.
import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
retry: {
attempts: 3,
backoffMs: 200,
maxBackoffMs: 1_000,
retryOnMethods: ['GET', 'HEAD'],
retryOnStatuses: [429, 503],
},
})
const response = await api.get('/status')attempts includes the initial request. For example, attempts: 3 permits up to
three total attempts, including up to two retries.
When an eligible request has attempts remaining, clearfetch retries a
NetworkError. It retries HTTP responses only when their status appears in
retryOnStatuses.
Retries are disabled by default. When enabled, they remain intentionally
conservative. Streaming request bodies are rejected when the request method is
eligible for multiple attempts. Retries are a convenience for bounded cases,
not a general resilience framework.
Retried FormData preserves field values and file contents, names, and media
types, but native multipart boundary encoding is not guaranteed to be
byte-for-byte identical between attempts. Pre-serialize a body when exact bytes
are part of an application signature or idempotency scheme.
import {
AbortRequestError,
createClient,
} from '@gavoryn/clearfetch'
const controller = new AbortController()
const api = createClient({
baseURL: 'https://api.example.com',
})
const promise = api.get('/reports/current', {
signal: controller.signal,
})
controller.abort(new Error('user cancelled'))
try {
await promise
} catch (error) {
if (error instanceof AbortRequestError) {
console.error('Request cancelled', error.cause)
}
}const api = createClient({
hooks: {
beforeRequest: [
async (context) => {
context.headers.set('x-client', 'clearfetch')
},
],
afterResponse: [
async (context) => {
console.log(context.response.status)
},
],
onError: [
async (context) => {
console.error(context.error)
},
],
},
})beforeRequest hook failures, request-normalization failures, retry rebuild
failures, and request-construction failures propagate as-is and are observable
through onError before being re-thrown. Retry-backoff aborts are normalized to
AbortRequestError, passed through onError, and re-thrown. Each
afterResponse hook receives its own cloned Response, so reading the body in
one hook does not consume the response used by another hook, normal parsing, or
HttpError creation.
If request-level hook configuration is invalid, valid client-level onError
hooks still observe the original normalization failure.
Hook scope is intentionally narrow:
beforeRequestmay mutate headers and may replace the URL with a final absolute URLafterResponseandonErrorare observational only apart from throwingcontext.optionsis read-only hook metadata, not a supported mutation surface
Client hooks run before request hooks. Within each client or request hook list,
hooks run in definition order. onError hooks are awaited and are not raced
against cancellation. Keep them bounded: a pending observer delays rejection,
and an observer that throws replaces the error delivered to the caller.
Cloned afterResponse inspection is intended for ordinary API payloads, not
large streaming or heavy binary workflows.
clearfetch has no built-in logging or telemetry. Applications that log request
diagnostics can use redactHeaders() to copy headers and replace common
sensitive values before writing application-owned diagnostics.
By default, it redacts exact case-insensitive matches for authorization,
cookie, set-cookie, proxy-authorization, x-api-key, and api-key.
import { createClient, redactHeaders } from '@gavoryn/clearfetch'
const api = createClient({
hooks: {
beforeRequest: [
(context) => {
const safeHeaders = redactHeaders(context.headers)
console.log(Object.fromEntries(safeHeaders))
},
],
},
})import {
AbortRequestError,
ConfigError,
HttpError,
NetworkError,
ParseError,
TimeoutError,
createClient,
isHttpClientError,
} from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
})
try {
await api.get('/users/123')
} catch (error) {
if (!isHttpClientError(error)) {
throw error
} else if (error instanceof HttpError) {
console.error(error.status, error.bodyText)
} else if (error instanceof ParseError) {
console.error(error.bodyText)
} else if (error instanceof TimeoutError) {
console.error(error.timeout)
} else if (error instanceof NetworkError) {
console.error('Network request failed', error.cause)
} else if (error instanceof AbortRequestError) {
console.error('Request cancelled', error.cause)
} else if (error instanceof ConfigError) {
console.error('Invalid request configuration', error.message)
}
}The exported error classes cover configuration, network, timeout, cancellation,
HTTP-status, and response-parsing failures. isHttpClientError() can identify
the library's error types before more specific instanceof handling.
Use HttpError.bodyText as the diagnostic payload. Capture is limited to
16,384 characters and a 250 ms read budget; it may be partial or absent.
Truncated captures include a ...[truncated] suffix after the captured text. Use HttpError.response
for status and headers, and do not assume its body remains readable.
import { createClient } from '@gavoryn/clearfetch'
const api = createClient({
baseURL: 'https://api.example.com',
})
const health = await api.get('/health', {
responseType: 'text',
})
const rawResponse = await api.get('/download', {
responseType: 'raw',
})
const textApi = createClient({
baseURL: 'https://api.example.com',
responseType: 'text',
})
const typedHealth: string = await textApi.get('/health')
const jsonStatus = await textApi.get<{ ok: boolean }>('/status', {
responseType: 'json',
})In raw mode, the per-attempt timeout ends when the Response is returned.
Pass a caller-owned AbortSignal if you need to cancel a later body read.
Body-read failures after return use native errors and do not invoke onError.
Non-2xx responses still throw HttpError, including in raw mode.
Client-level responseType defaults are reflected in the returned client type.
A request-level responseType still overrides the client default.
For explicit client annotations, use HttpClient<'text'>, HttpClient<'raw'>,
or the appropriate response mode. Plain HttpClient means JSON;
HttpClient<ResponseType> represents a client whose default mode is dynamic.
Non-JSON and dynamic clients cannot be assigned to a JSON client type.
Forwarding a RequestOptions value is supported; when its response mode is
unknown, the returned type includes all possible response results.
TypeScript generics describe the expected response shape, but they do not validate response data at runtime.
import { z } from 'zod'
import { createClient } from '@gavoryn/clearfetch'
const User = z.object({
id: z.string(),
name: z.string(),
})
const api = createClient({
baseURL: 'https://api.example.com',
})
const data: unknown = await api.get<unknown>('/users/123')
const user = User.parse(data)If you need end-to-end runtime safety, validate parsed data with a schema library such as Zod or Valibot after the request resolves.
Because successful empty JSON bodies resolve as undefined, handle that case
before runtime validation when an endpoint may return no content:
const data = await api.get<unknown>('/users/123')
if (data === undefined) {
throw new Error('Expected a response body')
}
const user = User.parse(data)- Non-2xx responses throw
HttpError. ParseError.bodyTextcapture is also bounded and may be truncated for very large invalid JSON payloads.- In JSON mode, successful empty bodies resolve as
T | undefined. - No default timeout is applied. Requests run until completion or external abort unless
timeoutis configured. - Timeout and retry-delay values may not exceed
2,147,483,647milliseconds, the maximum reliable platform timer delay. - After the timeout window starts, expiration remains authoritative through
afterResponsehooks and response parsing. - External cancellation also exits pending
beforeRequesthooks and prevents later hooks and fetch execution. Attempt timeouts still start only after these hooks complete; cancellation cannot stop work already started inside a consumer hook. - Pending
afterResponsehooks are raced against cancellation. Cancellation stops later hooks and proceeds to error observation; it cannot stop work already started inside a consumer hook. - Invalid request configuration, including invalid hook lists, fails fast with
ConfigError.createClient()andextend()also validate supplied client defaults during construction. - Invalid request abort signals fail with
ConfigError; native signals from another browser realm remain supported. - Hook, request-normalization, retry rebuild, and request-construction failures are not wrapped as
NetworkError. - Each
afterResponsehook receives an independently readable clonedResponsefor safe inspection. - Relative request inputs require
baseURL. beforeRequestmay override the URL only with a final absoluteURL, including aURLcreated in another browser realm.beforeRequestmay mutate headers, but hook option metadata is read-only.- Retry support is opt-in and conservative by default.
- Streaming request bodies are rejected only when the request method is eligible for multiple attempts.
- The
jsonhelper serializes request bodies and setsContent-Type: application/jsonwhen absent. bodyandjsoncannot be used together.- TypeScript rejects common invalid option combinations such as
bodyplusjson, and request bodies onGET/HEADrequest shapes. Runtime validation still protects JavaScript callers. - The package performs no telemetry or hidden network activity beyond the caller's request.
- Timeout windows are per attempt when retries are enabled. A configured
timeoutis not a total deadline across all retry attempts. - Custom
parseJsonfunctions may return a value or promise. Rejections becomeParseError, and active timeout or external-abort signals remain authoritative while an asynchronous parser is pending. - External abort signals surface as
AbortRequestError, including when the signal was aborted with a custom reason. - External abort beats timeout if it happens first; timeout beats external abort if the timeout fires first.
- Timeout aborts surface as
TimeoutError. - External abort reasons are preserved as
AbortRequestError.causewhen the platform exposes them. - Retry backoff waits are abortable.
- Retry attempts reuse a snapshot of the initially normalized URL, headers, retry policy, and request body. JSON bodies are serialized once before the first attempt.
- Client defaults are snapshotted at client creation, including mutable
URLvalues created in another browser realm. - Retryable
FormDatafile values that the current runtime cannot clone safely are rejected instead of being coerced into different payloads. - Timeout windows start after
beforeRequesthooks complete. - Retry backoff waits do not consume per-attempt timeout windows.
- If
beforeRequestreplacescontext.url, that replacement is final. Previously resolvedbaseURLand query parameters are not reapplied to the replacement URL. - Hook metadata includes
context.options.attemptandcontext.options.maxAttempts. Non-retried requests report attempt1and max attempts1. - When
queryserializes to a non-empty string, hook metadata includescontext.options.queryStringwithout a leading?. Existing search parameters from the input URL remain visible oncontext.url. - Query values are snapshotted once during normalization so retry and hook metadata do not re-read caller-owned accessors.
- The package stays close to native
fetchrather than inventing a separate transport model. - Hooks are intentionally narrower than axios-style interceptors.
- Retries are conservative and explicit, not aggressive or automatic.
- The package is ESM-only and targets modern runtimes only.
- The public API is intentionally small; missing features are often deliberate non-goals, not incomplete work.
clearfetch currently supports:
- Node.js
18.xand newer for package compatibility - modern browsers with native
fetch,Request,Response,Headers,URL, andAbortController - TypeScript
5.0through7.xfor the published declaration surface
The package is ESM-only and does not target legacy runtimes or polyfill-driven environments.
Features that accept Blob, File, FormData, URLSearchParams, or
ReadableStream require the corresponding native platform implementation.
For security-sensitive use, run clearfetch on a Node.js release line that is
still supported upstream; EOL
Node.js releases do not receive upstream security fixes.
- The package includes no built-in telemetry.
- The package performs no hidden network activity beyond the caller's request.
- Vulnerability reports should follow the policy in SECURITY.md.
CI checks the declared Node.js matrix, native HTTP integration, browser-like and real-Chromium behavior, and TypeScript 5.0, 6.0, and 7.x declarations. It also checks workflows, dependency origins, signatures, advisories, and package contents. Future TypeScript major versions require explicit validation.
Releases publish the exact smoke-tested tarball through GitHub Actions trusted publishing, verify npm integrity and provenance, and create the GitHub Release in a separate job. Manual workflow dispatch provides non-publishing validation. See the release policy for prerequisites, controls, commands, and recovery procedures. Local verification does not establish publication.
The public package surface is intentionally narrow:
- the root export provides the supported runtime API and public types
- internal implementation modules are not part of the supported import contract
NormalizedRequestOptionsis no longer exported in 2.0.0; useRequestOptions,ClientDefaults, orHookRequestOptionsfor the corresponding public boundary- the package includes no lifecycle scripts and is intended to publish only built
dist/artifacts - JavaScript source maps remain available for mapped stack traces; declaration maps are omitted because TypeScript source files are not shipped
- packed and unpacked artifact sizes and file counts are guarded by deliberate budgets
npm ci --ignore-scripts --registry=https://registry.npmjs.org: install locked development dependencies without lifecycle scriptsnpm run build: compile the package intodist/npm run benchmark: run the dependency-free local performance harness without enforcing timing thresholdsnpm run benchmark:smoke: run every benchmark scenario with minimal sampling to verify the harnessnpm run check:lockfile: validate lockfile origins, integrity, development-only scope, and the reviewed install-script allowlistnpm run check:dependency-audit: fail on moderate-or-higher known dependency advisoriesnpm run check:dependency-signatures: verify installed-package registry signatures and attestationsnpm run check:package-metadata: validate publish metadata and zero-runtime-dependency posturenpm run check:pack-smoke: smoke-test the packed tarball from a clean temporary installnpm run check:publish-dry-run: dry-run unpublished workspace versions; pass a retained.tgzto compare exact registry integrity for an existing version, or use-- --allow-existingonly for non-publishing validationnpm run lint: run TypeScript static checksnpm test: run unit and workflow-contract testsnpm run test:node-integration: run the public client against a deterministic localhost server through native Nodefetchnpm run test:browser-like: run browser-like package entrypoint coverage withhappy-domnpm run test:browser-real: build and run focused cross-realm coverage in Chromium; runnode node_modules/playwright/cli.js install chromiumonce before the first local invocationnpm run test:types-compat: build and compile a consumer fixture with the TypeScript 5.0 minimum, TypeScript 6.0 transition compiler, and current TypeScript 7.x compiler
The benchmark harness covers large query objects, retry-context rebuilding, response hooks, and retryable request bodies. Reports are environment-labeled observations rather than performance guarantees. See the benchmark guide for recording and comparison guidance.
clearfetch is published as @gavoryn/clearfetch. The main branch may be
ahead of the latest npm package until a matching release tag runs the Release
workflow. Check npm and GitHub Releases for the currently published version.
Project goals and behavior are documented in PURPOSE.md and DESIGN.md.