Dan Matthew

Writing · 6 Sep 2026

HTTP finally has a QUERY method, so I wrote the client for it

@danmat/query-fetch is a zero-dependency TypeScript client for HTTP QUERY (RFC 10008), with the fallbacks and retries the spec actually allows.

#typescript #http #npm #webdev

Every search endpoint I have ever built forced the same bad choice. Use GET with a query string and you get everything HTTP promises for a safe, idempotent request: proxies can cache it, clients can retry it, nobody worries about side effects. But a real filter object, the kind with nested conditions and a list of forty IDs, overflows URL length limits and ends up in every access log between you and the origin. Use POST and there is room for the body, but the method tells every proxy and client "this changes state." No caching, no safe retry.

HTTP QUERY is the missing third option. RFC 10008 defines a request that is safe and idempotent like GET but carries a body like POST. Intermediaries may cache it and clients may safely repeat it. It became a Proposed Standard in June 2026, so support is uneven, but modern fetch will already put QUERY on the wire if you ask.

What fetch will not do is enforce any of the spec's semantics. That gap is the library. @danmat/query-fetch is the client, and the first package under the @danmat scope on npm, where I publish small, sharp libraries: one job each, no runtime dependencies, typed end to end. I wanted the QUERY plumbing to exist in TypeScript before everyone reinvents it slightly differently.

HTTP QUERY (RFC 10008), made practical in TypeScript one call · three ways a search request can travel Client @danmat/query-fetch query(url, { json }) Content-Type · Accept · retry native fetch · zero deps Server /search reads the query body responds · may be cached 405 / 501 if it can't speak QUERY GET /search?filter=… safe · cacheable · but no body: URL limits, leaks into logs QUERY /search + body safe · idempotent · cacheable · carries the query in the body POST /search + body carries a body · but unsafe, uncacheable, no safe retry fallback: on 405 / 501, resend as POST with X-HTTP-Method-Override: QUERY transport: "post-override" skips the QUERY for an origin already known to reject it @danmat scope small, sharp libraries · one job each · the QUERY suite query-fetch the client · published first v0.3.0 accept-query Accept-Query header parse · build · negotiate query-cache body-aware caching the body is part of the key query-server server-side validation and negotiation
⤢ Enlarge

What it is

One source file, about 400 lines, zero dependencies, built on native fetch. It runs anywhere fetch does: Node 18 and up, Deno, Bun, Cloudflare Workers, the browser. The public surface is deliberately tiny.

query(input, options) returns a raw Response. The options type extends RequestInit minus method and body, so signal, credentials, and redirect pass straight through to fetch untouched. queryJson(input, options) is query plus an Accept: application/json header, a guard that throws on non-2xx, and the parsed body (typed by a generic) returned alongside the Response. QueryError is the single error class, covering construction-time mistakes and queryJson's failed statuses.

The rest is options: json for the common case, body with contentType for anything else, accept, and a fetch slot for your own implementation. That last one is also how the tests work.

The sharp edges I chose to own

Each behaviour in the library exists because the RFC hands you a specific trap.

Content-Type is mandatory. Servers must reject a QUERY body that has no content type, so prepare() throws a QueryError before the round trip instead of letting you discover it as a 400 afterwards. Fail at the door.

Servers that do not speak QUERY yet. A 405 or 501 means the origin honestly does not know the method. In that case query resends as POST with X-HTTP-Method-Override: QUERY, so override-aware backends still route it to the right handler. This fires only on those two statuses, never on an opaque throw, because an opaque throw could mean anything.

Origins that reject QUERY before any status comes back. A legacy proxy, or a CORS policy that allows POST but not QUERY, fails with nothing to react to. For those, transport: "post-override" skips the doomed QUERY attempt and posts with the override header from the start. It is per-call and opt-in. The library never flips to it on its own, because a POST is uncacheable unless the server honours the override, and that trade-off belongs to you, not to a default I picked.

Retry, because it is finally allowed. QUERY is idempotent by definition, so retrying transient failures is safe in a way it never is for POST. retry: 3 gets exponential backoff with jitter, honours Retry-After, and never retries after an abort. Off unless you ask.

The pattern across all four: add behaviour only where the spec makes it correct, and stay out of the way where the spec leaves the decision to the caller.

How it is built and shipped

tsup emits ESM and CJS from one entry, with declaration files, tree-shaking, and source maps; sideEffects: false and a conditional exports map do the rest. tsc --noEmit runs with strict and noUncheckedIndexedAccess.

Twenty vitest tests drive the library through a fake fetch that records every call, so I can assert the method, headers, and body of each attempt without touching a network. That is what the fetch option is really for. Biome lints on commit via husky. CI runs lint, typecheck, build, and tests on Node 18, 20, and 22. A v* tag runs npm publish --provenance, so every release on npm traces back to a specific workflow run.

The rest of the suite

query-fetch was first, but the theme has grown into four packages. @danmat/accept-query parses and negotiates the Accept-Query header. @danmat/query-cache does body-aware response caching, which sounds obvious until you remember that a QUERY's cache key has to include the body, or two different queries collide on the same URL. @danmat/query-server validates and negotiates on the receiving side. A runnable query-suite-example wires all four together with a live playground. Each package stays small on purpose.

Caveats, honestly

Browser integration is still being settled in whatwg/fetch. A cross-origin QUERY always preflights. And no client-side trick gets past a server that CORS-blocks both QUERY and POST. I track runtime behaviour as it ships rather than guessing ahead of it.

The package is at npmjs.com/package/@danmat/query-fetch and the code is at github.com/DanMat/query-fetch. If you run a QUERY endpoint and it does something the client does not expect, open an issue. That is exactly the runtime behaviour I want to track.