📖 Documentation & live examples →
Fch is not a wrapper around fetch. It extends the native URL class, so a single object is
both a real, fully mutable URL and the request that targets it. You build the URL with the standard
platform API, then execute it — with retries, timeouts, deduplication and typed body helpers on top.
import { Fch } from "@westtrade/fch";
const req = new Fch("https://api.example.com/v1/search");
// 1. It IS a URL — the standard API works, mutations affect the request.
req.pathname = "/v2/search";
req.searchParams.set("q", "hello");
req.hash = "results";
// 2. It is also the request — await it, or use a typed helper.
const [data, response] = await req.json<{ items: string[] }>();Because the URL and the client are one object, there is no separate "build a URL, then pass it to a
client" step. req instanceof URL is true.
- URL-first: inherits
URL—pathname,host,origin,searchParams,hash,toString(). - Retries: configurable attempts with exponential backoff and jitter.
- Timeouts: per-request timeout, independent of the retry loop.
- Interceptors: run before every attempt (requests) and before returning (responses).
- Logging: pluggable logger;
error/warnalways emit,info/debugare opt-in. - Body formats: JSON, FormData, URL-encoded, text, Blob, ArrayBuffer, ReadableStream.
- Dedupe: collapse concurrent identical in-flight requests, with
Idempotency-Keysupport. - Abort: cancel in-flight work and pending retry delays; the builder stays reusable.
- Awaitable: the instance is thenable (
await req), and also exposessend(),.json(),.text(),.blob().
The package is scoped as @westtrade/fch. The unscoped name fch on npm
belongs to an unrelated library, so npm install fch would install the wrong
code.
npm install github:westtrade/Fch#v1.13.1
# or
yarn add github:westtrade/Fch#v1.13.1It installs as @westtrade/fch regardless of how you fetch it:
import { Fch } from "@westtrade/fch";Pin a release tag (see tags) so an
install is reproducible; omit #v1.13.1 to follow the default branch.
The package has no runtime dependencies. A git install compiles dist/ for you
via prepare; if you vendor the source, run npm install && npm run build once.
import { Fch } from "@westtrade/fch";
const req = new Fch("https://api.example.com/v1/items?page=1");
req.searchParams.set("page", "2"); // standard URLSearchParams
req.searchParams.append("tag", "new");
req.pathname = "/v2/items"; // standard URL property
console.log(req.toString()); // https://api.example.com/v2/items?page=2&tag=new
await req.send(); // one request, to exactly that URLconst response = await new Fch("https://api.example.com/data", { timeout: 3000 });const [user] = await new Fch("https://api.example.com/users")
.setJsonBody({ name: "Alice", role: "admin" })
.json<{ id: number; name: string }>();const api = new Fch("https://api.example.com/orders", {
retries: 3, // 1 initial attempt + 3 retries
retryDelay: 500, // base delay; exponential backoff with jitter
maxRetryDelay: 10_000,
timeout: 5_000,
debug: true,
})
.setAuthToken(process.env.TOKEN!)
.before((req) => {
// runs before EVERY attempt, so a refreshed token is picked up
req.setHeader("X-Request-Id", crypto.randomUUID());
})
.after(async (res) => {
if (res.status === 401) throw new Error("Unauthorized");
return res;
})
// decide what is worth retrying; without this only network errors retry
.setRetryOn((response, error, attempt) => {
if (response) return response.status === 429 || response.status >= 500;
return attempt < 3;
});
try {
const [order] = await api.json();
} catch (error) {
console.error("Request failed after retries:", error);
}const req = new Fch("https://api.example.com/profile", { dedupe: true });
// Both share one in-flight request; each caller gets its own readable response.
const [a, b] = await Promise.all([req.json(), req.json()]);const req = new Fch("https://api.example.com/slow", { retries: 5 });
const pending = req.send();
req.abort(); // cancels the request and any pending retry delay
await pending.catch(() => {});
await req.send(); // the builder is still usable afterwardsA <form> element or a submit Event is accepted anywhere a body goes — no manual
new FormData() needed. All of these do the same thing:
form.onsubmit = (e) => api.post("/houses", new FormData(e.target));
form.onsubmit = (e) => api.post("/houses", e); // the event itself
form.onsubmit = (e) => api.post("/houses", e.target); // the <form>
form.onsubmit = (e) => api.post("/houses", form); // the elementThe form's own method attribute is honoured (a <form method="post"> posts), the
clicked submit button's name/value is included, and Content-Type is left to the
runtime so it can set the correct multipart/form-data; boundary=…. A
<form method="get"> puts its fields in the query string and sends no body, as a
browser would. This also works on the builder API:
const req = new Fch("https://api.example.com/houses");
req.setFormData(e); // or .setBody(e)
await req.send();fch.create() mirrors the ergonomics of Axios-style clients. Shortcut calls start
their request immediately, so fire-and-forget handlers work without await:
const api = fch.create({ baseUrl: "https://api.example.com" });
api.get("/houses");
api.post("/houses", { name: "Cute Cottage" }); // object → JSON + Content-Type
api.put("/houses/1", new Blob([png])); // binary untouched
api.delete("/houses/1", { timeout: 5000 }); // 2nd arg is options, not a body
form.onsubmit = (e) => { e.preventDefault(); api.post("/houses", e); }; // not awaited
// A single call resolves to [parsedBody, response].
const [houses, response] = await api.get<House[]>("/houses");
// Need the builder instead? Ask for one with the shared defaults applied.
api.request("/houses").setAuthToken(token).json();Because Fch extends URL, an absolute URL is required — either pass a full URL or
set baseUrl via fch.create(). A relative path without a base throws a descriptive
TypeError rather than an opaque Invalid URL.
Options passed to fch.create() (retries, timeout, headers, …) apply to every
shortcut call, and per-call options override them. get, head and delete take
options as their second argument; post, put and patch take a body first and
options second.
| Area | Members |
|---|---|
| URL (inherited) | pathname, host, origin, searchParams, hash, toString() |
| Execute | send(), json(), text(), blob(), makeRequest(), then(), catch(), finally() |
| URL helpers | setSearchParams() (replaces the query), appendSearchParams() (adds to it) |
| Bodies | setJsonBody(), setFormData(), appendToFormData(), setFormUrlEncodedBody(), setBody() |
| Shortcuts | fch.create({ baseUrl }) → get(), head(), post(), put(), patch(), delete(), request() |
| Headers | setHeader(), setHeaders(), getHeader(), deleteHeader(), setAuthToken(), setBasicAuth() |
| Request config | setMethod(), setTimeout(), setRetries(), setRetryDelay(), setMaxRetryDelay(), setRetryOn(), setFetchOptions(), setCORS(), disableCache() |
| Dedupe | setDedupeKey() |
| Interceptors | before(), after() |
| Deprecated aliases | @westtrade/fch/plugins/compat — setRetryTimeout, setIdempotencyKey, addRequestInterceptor, addResponseInterceptor |
| Logging | @westtrade/fch/plugins/logger — getLogger(), setLogger(), enableLogging(), disableLogging() |
| Lifecycle | abort(), aborted, clone(), start() |
| Polling | poll(delay?) — async generator of repeated responses; delay is used exactly, not clamped by maxRetryDelay |
The three positioning goals (AI-first token economy, human-first simplicity, minimal bundle size) are tracked with measurements in ROADMAP.md.
MIT