Fluent, extensible HTTP + WebSocket client for Node, browsers, and edge runtimes.
MIT · Node ≥ 20 · ESM + CJS
pnpm add @questorylabs/qhttpOptional peers:
pnpm add ws # Node WebSockets
pnpm add http-cache-semantics # RFC 9111 HTTP cache mode| Import | Use for |
|---|---|
@questorylabs/qhttp |
QHttp, cache engines, adapters, utilities |
@questorylabs/qhttp/ws |
QWebSocket, browser/Node WS adapters |
@questorylabs/qhttp/http2 |
Http2Adapter (Node only) |
@questorylabs/qhttp/http-cache |
HTTP cache policy helpers |
@questorylabs/qhttp/react |
ResourceStore, useResource, useAction, useLiveResource |
Framework-agnostic ResourceStore plus thin React bindings — not a TanStack Query clone.
import { ResourceProvider, useResource, useAction, useStore } from '@questorylabs/qhttp/react';
function App() {
return (
<ResourceProvider defaults={{ freshFor: 30_000, retries: 1 }}>
<Dashboard />
</ResourceProvider>
);
}
function Dashboard() {
const stats = useResource({
id: ['dashboard'],
load: () => fetch('/api/stats').then((r) => r.json()),
refreshEvery: 30_000,
});
const save = useAction({
run: (name: string) => fetch('/api/profile', { method: 'PATCH', body: name }),
touches: [['me'], ['dashboard']],
});
if (stats.empty) return <p>Loading…</p>;
return <pre>{JSON.stringify(stats.value)}</pre>;
}Resource results expose value, empty, busy, refreshing, failed, ready, and reload().
Live SSE/WebSocket feeds use useLiveResource with an injected subscribe callback.
Chainable — configure once, call many times:
import { QHttp } from '@questorylabs/qhttp';
const client = new QHttp({ baseUrl: 'https://api.example.com' })
.setUrl('/users/{{userId}}')
.replaceUrlMacros({ userId: 'u1' })
.setQueryParams({ page: 1 })
.setHeaders({ 'X-App': 'demo' })
.setTimeout(5000)
.cache()
.cacheKey('users:{{userId}}')
.preRequest((ctx) => {
ctx.headers.set('x-timezone', 'UTC');
})
.postRequest(({ result }) => {
result.data = { ...(result.data as object), transformed: true };
});
const { data, httpStatus, fetchStatus } = await client.get();Imperative — mutate between calls:
const req = new QHttp();
req.setBaseUrl('https://api.example.com');
req.setUrl('/recommendations');
if (userId) req.setQueryParams({ userId });
const result = await req.get();WebSocket:
import { QWebSocket } from '@questorylabs/qhttp/ws';
const socket = new QWebSocket('wss://example.com/socket')
.setReconnect({ retries: 5, delay: 1000, backoff: 'exponential' })
.setHeartbeat({ intervalMs: 30000, message: 'ping', pongTimeoutMs: 5000 })
.onMessage((msg) => console.log(msg.data))
.connect();
socket.sendJson({ type: 'subscribe', channel: 'orders' });Full guides with examples, guidelines, and API notes live in [docs/](./docs/README.md).
| Guide | Topics |
|---|---|
| Getting started | Concepts, fetchStatus, result shape, clone/reset/cancel |
| Requests | URLs, macros, query params, body types, response parsing |
| Caching | TTL + HTTP cache modes, engines, keys, cacheWhen |
| Hooks | preRequest, postRequest, onError, custom phases |
| Retry & errors | Backoff, jitter, Retry-After, QHttpError codes |
| Authentication | Bearer and Basic auth |
| Adapters | Fetch default, HTTP/2, custom transports |
| WebSockets | Reconnect, heartbeat, Node ws adapter |
| API reference | Method and type cheat sheet |
| Area | Highlights |
|---|---|
| API style | Chainable builders + imperative setters on the same client |
| Lifecycle | fetchStatus: idle → loading → success |
| URLs | Base URL joining, query serialization, {{macro}} templates |
| Hooks | preRequest, postRequest, onError, onRetry, preCache, postCache, custom phases |
| Retry | Exponential/fixed backoff, jitter, Retry-After header, idempotent-method defaults |
| Cache | TTL key/value store (~476k hits/s) + optional RFC 9111 HTTP mode via .httpCache() |
| Progress | Upload + download via onProgress (FormData / streams included) |
| Transport | Pluggable HttpAdapter; fetch by default, optional HTTP/2 on Node |
| WebSocket | Reconnect, heartbeat, send queue, browser + Node adapters |
| Runtimes | Node, browsers, edge — no framework lock-in |
- No HTTP/3 adapter in core — use
.setAdapter()for custom transports Http2Adapterdoes not supportFormDataorReadableStreamrequest bodies (string / Buffer / ArrayBuffer / typed arrays / Blob /URLSearchParamsonly). Upload progress wrapping uses streams, so use the default fetch adapter when you need upload progress- FormData upload
totalis estimated from field sizes (not exact multipart byte length); opaque streams only gettotalwhenContent-Lengthis set - In-memory cache state does not persist across serverless cold starts
- HTTP cache mode (
.httpCache()) requireshttp-cache-semantics; TTL mode (.cache()) has no extra deps - HTTP cache stores one variant per key — use explicit keys when caching multiple
Varyrepresentations
MIT