Skip to content

DanMat/query-fetch

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 });

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 OPTIONS accordingly.
  • 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

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors