@danmat/query-fetch
v0.2.0
Published
A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the safe, idempotent request with a body. Content-Type enforcement, POST fallback, and Accept negotiation over native fetch.
Maintainers
Readme
@danmat/query-fetch
A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the request that is safe and idempotent like GET, but carries a body like POST, and caches like neither before it could.
Built on native fetch. Works in Node 18+, Deno, Bun, Cloudflare Workers, and the browser.
import { query } from "@danmat/query-fetch";
const res = await query("https://api.example.com/search", {
json: { filter: { status: "active" }, sort: "-createdAt", limit: 50 },
});Why QUERY?
For years you had two bad options for a search endpoint:
GETwith a query string — safe, idempotent, cacheable… but your filter blows past URL length limits and leaks into logs.POSTwith a body — room for a rich query… but it's neither safe, idempotent, nor cacheable, so proxies and clients treat it as a state change.
QUERY is the missing third option: a body-carrying request that intermediaries may cache and clients may safely retry. This library handles the sharp edges the spec introduces.
Install
npm install @danmat/query-fetchWhat it does for you
Scripted fetch(url, { method: "QUERY", body }) already works in modern runtimes — but the semantics of RFC 10008 are on you. This library covers them:
- ✅ Enforces
Content-Type— the RFC requires servers to reject a QUERY whose body has no content type. We throw before the round-trip instead of letting you debug a400. - ✅ Transparent
POSTfallback — servers that don't understand QUERY yet respond405/501; we automatically retry asPOSTand advertise the original method viaX-HTTP-Method-Overrideso override-aware backends still route it correctly. - ✅
Acceptnegotiation — pass a media type (or list) to negotiate the response format the RFC'sAccept-Querydance is built around. - ✅ Safe automatic retry (opt-in) — QUERY is idempotent by definition, so retrying transient failures is safe here in a way it never is for
POST. Exponential backoff + jitter, honoursRetry-After. - ✅ Redirect-safe — the RFC's
303 See Otherindirect-result pattern is handled byfetch's own redirect following; nothing surprising here. - ✅ Zero dependencies, fully typed, tree-shakeable, dual ESM/CJS.
Usage
JSON queries
import { queryJson } from "@danmat/query-fetch";
const { data, response } = await queryJson<{ total: number }>(
"https://api.example.com/search",
{ json: { q: "http query method" } },
);
console.log(data.total, response.headers.get("age"));queryJson sets Accept: application/json, throws on a non-2xx status, and returns the parsed body alongside the raw Response.
Raw bodies with an explicit content type
await query("https://api.example.com/search", {
body: "SELECT * WHERE status = 'active'",
contentType: "application/sql",
accept: "application/json",
});Automatic retry (safe, because QUERY is idempotent)
RFC 10008 defines QUERY as safe and idempotent — so unlike POST, retrying a
failed request can't cause a double-effect. Opt in with a count, or an object
for full control:
// Retry up to 3 times with exponential backoff + jitter.
await query(url, { json, retry: 3 });
// Full control.
await query(url, {
json,
retry: {
retries: 5,
minDelay: 200, // base backoff (ms)
maxDelay: 10_000,
factor: 2,
jitter: true,
respectRetryAfter: true, // honour Retry-After on 429/503
retryOn: ({ response, error }) =>
Boolean(error) || (response?.status ?? 0) >= 500,
onRetry: ({ attempt, delay }) => console.warn(`retry #${attempt} in ${delay}ms`),
},
});By default it retries network errors and 408/425/429/500/502/503/504, and
does not retry aborts. Retry is off unless you set it. (Retries reuse a
buffered body — a string, bytes, or json; a streaming body is sent once.)
Opt out of the POST fallback
await query(url, { json, fallbackToPost: false });Bring your own fetch
import { fetch as undiciFetch } from "undici";
await query(url, { json, fetch: undiciFetch });API
query(input, options?): Promise<Response>
Performs a QUERY request. options extends RequestInit (so signal, credentials, redirect, etc. all work), minus method and with a richer body:
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| body | BodyInit \| null | — | Raw query body. Pair with contentType. |
| json | unknown | — | Value serialized to JSON; sets application/json. |
| contentType | string | — | MIME type of body. Required when a body is present. |
| accept | string \| string[] | — | Sets the Accept header. |
| fallbackToPost | boolean | true | Retry as POST on 405/501. |
| methodOverrideHeader | string \| false | "X-HTTP-Method-Override" | Header advertising the original method on fallback. |
| retry | number \| RetryOptions | off | Auto-retry transient failures (safe: QUERY is idempotent). |
| fetch | typeof fetch | globalThis.fetch | Custom fetch implementation. |
queryJson<T>(input, options?): Promise<{ data: T; response: Response }>
query + JSON parsing + a non-2xx guard.
QueryError
Thrown for construction-time problems (a body without a content type, no available fetch) and non-2xx responses in queryJson.
Caveats & status
QUERY is a Proposed Standard (June 2026). Two things to know:
- CORS: QUERY is not a CORS-safelisted method, so a cross-origin QUERY triggers a preflight. Your server must handle
OPTIONSaccordingly. - Spec churn: browser-integration details (method normalization, caching) are still being ironed out in whatwg/fetch#1938. This library tracks runtime behavior as it ships.
The @danmat QUERY suite
@danmat/query-fetch— client for the QUERY method (you are here).@danmat/accept-query— parse/build/negotiate theAccept-Queryheader.@danmat/query-cache— body-aware response caching.@danmat/query-server— server-side request validation & negotiation.
▶️ See them work together: query-suite-example — a runnable demo using all four, with a 🌐 live playground.
License
MIT © Dan Matthew
