Skip to content

Repository files navigation

@danmat/query-fetch

CI npm version minified + gzip size License: MIT

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:

  • GET with a query string — safe, idempotent, cacheable… but your filter blows past URL length limits and leaks into logs.
  • POST with 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-fetch

What 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 a 400.
  • Transparent POST fallback — servers that don't understand QUERY yet respond 405/501; we automatically retry as POST and advertise the original method via X-HTTP-Method-Override so override-aware backends still route it correctly.
  • Accept negotiation — pass a media type (or list) to negotiate the response format the RFC's Accept-Query dance 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, honours Retry-After.
  • Redirect-safe — the RFC's 303 See Other indirect-result pattern is handled by fetch'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 });

Force POST override for an origin you know rejects QUERY

The automatic fallback only triggers when the server answers 405/501. Some origins reject QUERY before that — a legacy proxy, or a cross-origin server whose CORS allows POST but not QUERY yet — so the QUERY throws (or its preflight fails) with no status to react to. When you already know an origin is like that, skip the doomed QUERY and POST with the override from the start:

await query(url, { json, transport: "post-override" });

This is a deliberate, per-call opt-in. The library never switches to it on its own, because a POST is treated as unsafe and uncacheable by intermediaries unless the server honors the override — so the choice stays yours.

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.
transport "query" | "post-override" "query" "post-override" sends POST + override from the start, for an origin you already know rejects QUERY.
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, and the browser blocks it unless the server returns QUERY in Access-Control-Allow-Methods. When it doesn't, fetch throws with no status, so the automatic 405/501 fallback can't see it. For an origin you know is in that state, pass transport: "post-override" to POST from the start — provided that server allows POST (and the override header) via CORS. There is no client-only way around a server that CORS-blocks both.
  • 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

▶️ See them work together: query-suite-example — a runnable demo using all four, with a 🌐 live playground.

License

MIT © Dan Matthew

About

A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — safe, idempotent requests with a body, over native fetch.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages