A lightweight, typed HTTP client for browsers and Node.js, built on native Fetch with retries, timeouts, and progress tracking.
- ✅ Ready-to-use default client
- ✅ Configured instances with
ft.create() - ✅ Typed response shortcuts
- ✅ Browser file downloads with
.download() - ✅ JSON bodies and search parameters
- ✅ Timeout and opt-in retries
- ✅ Typed HTTP, network, and timeout errors
- ✅ Structured error information with
errorInfo() - ✅ Upload and download progress with native streams
- ✅ Request, response, retry, error, and status callbacks
- ✅ Automatic browser/server base URL selection
- ✅ Opt-in server header forwarding through a safe allowlist
- ✅ Native
RequestInitoptions - ✅ Zero runtime dependencies
npm install reqly-jsNode.js 22 or a modern browser with the native Fetch API is required.
Import the ready-to-use client:
import ft from "reqly-js";
type Account = {
email: string;
id: string;
};
const account = await ft.get("https://api.example.com/accounts/123").json<Account>();Or create a configured instance:
import ft from "reqly-js";
export const api = ft.create({
baseUrl: "https://api.example.com",
headers: {
accept: "application/json",
},
prefix: "v1",
});
const account = await api.get("accounts/123").json<Account>();The resulting URL is https://api.example.com/v1/accounts/123.
For applications with separate browser and server paths:
const api = ft.create({
baseUrl: {
client: "/proxy",
server: "http://api:4000",
},
});The browser uses client; Node.js and other runtimes without window use server.
All methods accept an optional URL and request options:
ft.get(url, options);
ft.post(url, options);
ft.put(url, options);
ft.patch(url, options);
ft.delete(url, options);
ft.head(url, options);Calling a method starts one request operation and returns a FetchTask. The task can be awaited as a native Response or consumed through a body shortcut.
const response = await api.get("accounts/123");
const sameResponse = await api.get("accounts/123").response();Use json to serialize a JSON body and set content-type: application/json when it is not already configured:
const account = await api
.post("accounts", {
json: {
email: "user@example.com",
name: "Example User",
},
})
.json<Account>();Use body for any native BodyInit value:
const form = new FormData();
form.set("avatar", file);
const account = await api
.post("accounts/avatar", {
body: form,
})
.json<Account>();json and body are mutually exclusive.
Search parameters can be configured on the instance and overridden per request:
const api = ft.create({
baseUrl: "https://api.example.com",
searchParams: {
locale: "en",
},
});
const accounts = await api
.get("accounts", {
searchParams: {
page: 2,
role: ["OWNER", "ADMIN"],
},
})
.json<Account[]>();Supported values are strings, numbers, booleans, null, undefined, and arrays of those values. null and undefined are omitted. Request parameters replace instance parameters with the same name.
An input Request already owns its URL. Passing request-specific searchParams with it throws instead of silently ignoring them.
api.get("data").json<MyType>();
api.get("data").text();
api.get("data").blob();
api.get("file").download({ filename: "report.pdf" });
api.get("data").arrayBuffer();
api.get("data").formData();
api.get("data").response();json<T>() defaults to unknown. The generic type provides compile-time typing only; it does not validate the response at runtime. Empty or invalid JSON rejects with the native parsing error.
Use .download() to save a response directly in the browser:
await api
.post("invoices/pdf", {
json: { invoice_id: "invoice-id" },
onDownloadProgress: ({ percent, transferred, total }) => {
console.log(percent, transferred, total);
},
})
.download({ filename: "invoice.pdf" });The explicit filename has priority. When omitted, the name is read from the standard Content-Disposition response header and falls back to download. Path segments are removed from filenames before the browser receives them.
.download() is browser-only and rejects with a TypeError in server runtimes. Use .blob(), .arrayBuffer(), or .response() when the response must be processed on the server.
| Property | Type | Default | Description |
|---|---|---|---|
baseUrl |
string | URL | RuntimeBaseUrl |
— | Static URL or automatic client/server URLs. |
prefix |
string |
— | Path inserted between baseUrl and the request path. |
searchParams |
SearchParams |
— | Parameters included in every request. |
headers |
HeadersInit |
— | Headers included in every request. |
forwardHeaders |
boolean | { extra: string[] } |
false |
Forwards allowlisted incoming headers on the server. |
getHeaders |
() => HeadersInit | Promise<HeadersInit> |
— | Provides the current incoming server headers. |
timeout |
number | false |
false |
Request timeout in milliseconds. |
retry |
number | RetryConfig | false |
false |
Enables retries. A number is the retry limit. |
throwHttpErrors |
boolean |
true |
Throws HTTPError for non-2xx responses. |
beforeRequest |
BeforeRequest |
— | Runs before every attempt. |
afterResponse |
AfterResponse |
— | Runs after every received response. |
onRetry |
OnRetry |
— | Runs before a retry delay. |
onError |
OnError |
— | Observes or replaces the final error. |
onStatus |
StatusHandlers |
— | Runs an action for the final response status. |
All other native RequestInit properties, such as cache, credentials, mode, and redirect, are supported.
Request options support the same reliability, lifecycle, and native options, plus:
| Property | Type | Description |
|---|---|---|
json |
unknown |
Serializes a JSON request body. |
body |
BodyInit | null |
Sends a native request body. |
searchParams |
SearchParams |
Adds or replaces search parameters. |
signal |
AbortSignal |
Cancels the request without being replaced by the timeout signal. |
onUploadProgress |
(progress) => void |
Reports native upload stream progress. |
onDownloadProgress |
(progress) => void |
Reports native download stream progress. |
Request-specific lifecycle callbacks replace the matching instance callback. They are not silently chained.
Runtime selection and header filtering are framework-independent. Only the function that obtains the current incoming request headers belongs to the application:
const api = ft.create({
baseUrl: {
client: "/proxy",
server: process.env.API_URL!,
},
getHeaders: async () => {
const { headers } = await import("next/headers");
return headers();
},
forwardHeaders: true,
});forwardHeaders: true enables the built-in allowlist:
accept-language
cf-connecting-ip
origin
referer
sec-ch-ua
sec-ch-ua-mobile
sec-ch-ua-platform
sec-fetch-dest
sec-fetch-mode
sec-fetch-site
sec-fetch-user
true-client-ip
user-agent
x-forwarded-for
x-forwarded-host
x-forwarded-port
x-forwarded-proto
x-real-ip
Add application-specific headers without replacing the defaults:
const api = ft.create({
baseUrl: {
client: "/proxy",
server: process.env.API_URL!,
},
getHeaders,
forwardHeaders: {
extra: ["x-tenant-id"],
},
});The property is disabled when omitted or set to false. On the server, enabling it without getHeaders throws a configuration error. In the browser, getHeaders is not called because the browser controls its own outgoing request headers.
cookie and authorization are intentionally excluded from the default allowlist. Add them explicitly only when the destination is trusted:
forwardHeaders: {
extra: ["cookie", "authorization"],
}Forwarded headers have the lowest priority. Instance headers, headers from an input Request, and request-specific headers override them in that order. getHeaders is called once per operation, not once per retry.
The application and its reverse proxy remain responsible for ensuring IP and forwarding headers are trustworthy before they reach the fetcher.
Retries are disabled by default. Enable them with a number:
const api = ft.create({
retry: 2,
});Or configure them explicitly:
const api = ft.create({
retry: {
baseDelay: 300,
jitter: true,
limit: 2,
maxDelay: 30_000,
methods: ["GET", "HEAD"],
statusCodes: [408, 429, 500, 502, 503, 504],
},
});Only GET and HEAD are retried by default. Add mutation methods explicitly only when the endpoint is idempotent. Request streams are never replayed or buffered silently. A Retry-After value within maxDelay is followed exactly without jitter. Responses requesting a longer delay are not retried.
const account = await api
.get("accounts/123", {
timeout: 10_000,
})
.json<Account>();The timeout covers all attempts and retry delays until the final response headers are received. A timeout throws TimeoutError. A user-provided AbortSignal remains independent and preserves its own abort reason.
const api = ft.create({
beforeRequest: ({ attempt, isServer, request }) => {
request.headers.set("x-attempt", String(attempt));
request.headers.set("x-runtime", isServer ? "server" : "client");
},
afterResponse: ({ response, attempt, request, isServer }) => {
console.log(response.status, attempt, request, isServer);
},
onRetry: ({ attempt, delay, error, isServer }) => {
console.log({ attempt, delay, error, isServer });
},
onError: ({ error, attempt, request, isServer }) => {
console.log({ error, attempt, request, isServer });
return new Error("API request failed", { cause: error });
},
});The order is:
beforeRequest
-> fetch
-> afterResponse
-> onRetry (when another attempt will run)
-> onStatus (final response only)
-> onError (final fetcher error only)
afterResponse may return a replacement Response. onError may return a replacement Error.
Every lifecycle callback receives isServer, calculated once when the request operation starts.
onStatus runs after retries and before an HTTPError is created:
const api = ft.create({
onStatus: {
401: ({ isServer, request }) => {
if (!isServer) {
window.location.replace("/login");
}
console.log("Unauthorized", request.url);
},
503: () => {
throw new Error("Maintenance mode");
},
},
});Errors thrown by a status action propagate unchanged and do not pass through onError. This allows the application to use its own routing or control-flow mechanism.
await api
.post("upload", {
body: file,
onUploadProgress: ({ percent, transferred, total }) => {
console.log({ percent, transferred, total });
},
})
.json();
await api
.get("download", {
onDownloadProgress: ({ percent, transferred, total }) => {
console.log({ percent, transferred, total });
},
})
.download({ filename: "download.bin" });total and percent are null when the runtime or server does not provide a known size. Upload progress depends on native request stream support. The package does not switch to XMLHttpRequest or another transport.
import { errorInfo, FetchError, HTTPError, NetworkError, TimeoutError } from "reqly-js";HTTPErrorexposesrequestandresponse.NetworkErrorexposesrequestand the native error throughcause.TimeoutErrorexposesrequestandtimeout.FetchErroris the shared base class.
Use errorInfo() to obtain a consistent result without consuming the original response:
try {
await api.get("accounts");
} catch (err) {
const { code, message, status } = await errorInfo(err);
console.log({ code, message, status });
}It reads error, message, and code from JSON error responses. HTTP errors use the response status; errors without an HTTP response use status 0. Unknown errors return Request failed.
This version contains only the framework-independent native Fetch client. It can select browser/server URLs and filter incoming headers, but it never imports a framework or discovers a framework request context by itself. The consuming application provides that context through getHeaders. Authentication, session management, and application caching remain outside the package.
MIT