snapreq
v0.0.17
Published
Cross-platform HTTP and WebSocket client with one API across Node, web, Expo and React Native
Maintainers
Readme
snapreq
A cross-platform HTTP and WebSocket client with one API across Node, the web, Expo and React Native.
The same code runs everywhere. Where a platform cannot provide a feature (Unix sockets in a browser, request-body compression over fetch, …) snapreq raises a clear SnapReqUnsupportedFeatureError instead of silently changing behaviour — so the API is identical and the gaps are explicit.
- Zero runtime dependencies.
- Picks the right transport at runtime: Node
http/https,fetch, orXMLHttpRequest. node:*modules are loaded with a dynamicimport()from the Node transport only, so web/Expo bundlers (Metro, webpack, …) never try to bundle them.- ESM + JSDoc types, checked with
tsc.
Install
npm install snapreqImports
snapreq has no barrel entry point — you import each piece from its own subpath so a bundler (Metro/Expo, webpack, …) only pulls in what you use, and the HTTP client never drags the WebSocket client into your bundle:
import SnapReq from "snapreq" // the HTTP client
import SnapReqWebSocketClient from "snapreq/websocket"
import {SnapReqHttpError, SnapReqIdleTimeoutError, SnapReqRedirectError, SnapReqResponseTooLargeError, SnapReqUnsupportedFeatureError} from "snapreq/errors"
import {defaultRetryableError} from "snapreq/retry"
import SnapReqResponse from "snapreq/response"
import SnapReqHeaders from "snapreq/headers"HTTP
import SnapReq from "snapreq"
const client = new SnapReq({baseUrl: "https://api.example.com"})
const response = await client.get("/users", {query: {page: 1}})
const users = await response.json()
await client.post("/users", {name: "Ada"})Every call returns a SnapReqResponse:
response.status // 200
response.ok // status in 200..299
response.headers.get("content-type")
await response.json() // parsed JSON (null for an empty body)
await response.text() // UTF-8 string
await response.bytes() // Uint8Array
await response.buffer() // Node Buffer (Node only)request() does not throw on a non-2xx status by default (like fetch). Pass throwOnError: true (per request or on the client) to get a SnapReqHttpError carrying status, responseText and the response.
Client options
new SnapReq({
baseUrl, // origin (and optional base path) prefixed onto relative paths
headers, // default headers — object or a factory `() => ({...})` for dynamic auth
retry, // default retry policy (see below)
throwOnError, // throw SnapReqHttpError on non-2xx (default false)
timeoutMs, // absolute request/body deadline; per-request 0 disables it
idleTimeoutMs, // inactivity timeout reset by transport progress; per-request 0 disables it
credentials, // fetch credentials mode: "omit" | "same-origin" | "include"
redirect, // explicit "error" | "manual" | "follow"; omitted keeps transport behavior
maxRedirects, // followed-redirect maximum (default 10)
maxResponseBytes, // decoded response-body limit; omitted means unlimited
transport, // "auto" (default) | "node" | "fetch" | "xhr" | a transport instance
// Node transport only:
socketPath, // connect over a Unix domain socket
tls, // {ca, cert, key, rejectUnauthorized}
keepAlive // reuse connections (default true)
})Redirects and response bounds
Redirect behavior remains transport-native when redirect is omitted, which
preserves existing applications. Set an explicit policy when redirect handling
is security-sensitive: manual exposes the 3xx response, error raises
SnapReqRedirectError, and follow follows at most maxRedirects responses.
Follow mode removes Authorization, Cookie, and Proxy-Authorization when
the redirect changes origin while retaining non-credential headers such as
Range. Fetch implementations that expose manual redirect responses support
all three policies. Browser Fetch commonly returns an opaque redirect instead;
in that case error still rejects the redirect, while manual and follow
raise SnapReqUnsupportedFeatureError because the status, target URL, and
headers required to implement them safely are hidden. XHR and the proxy-bounce adapter raise
SnapReqUnsupportedFeatureError because those transports cannot observe the
target redirect response reliably.
maxResponseBytes bounds decoded bytes before they are returned by stream()
or retained by bytes(), text(), json(), and buffer(). A declared
Content-Length over the limit rejects before body consumption; chunked or
compressed responses are counted as decoded chunks and raise
SnapReqResponseTooLargeError on overflow. The limit can be set on the client
or overridden per request; omission keeps the existing unlimited behavior.
The Node transport and Fetch implementations with a readable response stream
enforce the bound incrementally. Fetch buffered fallbacks, XHR, and
proxy-bounce reject a configured bound with SnapReqUnsupportedFeatureError
because they allocate or receive a complete buffered response before SnapReq
can count it.
Timeouts
Set timeoutMs on the client or a single request for an absolute wall-clock deadline. It covers one request attempt, including response headers and body reads through json(), text(), bytes(), buffer(), or stream(), and is never reset by progress. Set idleTimeoutMs independently to bound inactivity while allowing a healthy transfer to exceed that interval. Its watchdog resets when outbound body bytes are accepted, response headers arrive, or inbound body bytes are delivered. Zero disables the corresponding client default.
An absolute deadline rejects with SnapReqTimeoutError (timeoutKind: "overall"). Inactivity rejects with SnapReqIdleTimeoutError, a backward-compatible SnapReqTimeoutError subtype carrying timeoutKind: "idle", idleTimeoutMs, and the last progress phase. Caller signal cancellation remains SnapReqAbortError. All controls abort the active transport and interrupt retry waits without starting another attempt.
const client = new SnapReq({baseUrl: "https://api.example.com", timeoutMs: 120000, idleTimeoutMs: 30000})
await client.get("/slow", {timeoutMs: 5000})
await client.get("/long-running", {timeoutMs: 0}) // disable the client default for this callThe Node transport supports idle progress for streaming uploads. XHR uses its native upload/download progress events. Fetch supports semantically bodyless responses and observable ReadableStream downloads; it explicitly rejects request-body uploads and non-bodyless buffered-response fallbacks whose progress Fetch cannot expose. The proxy-bounce transport explicitly rejects idle timeouts because it buffers the proxied response.
Retry
Retries transient network errors and retryable HTTP statuses (502/503/504 by default). Only applies to buffered requests — never to streamed bodies.
await client.get("/flaky", {retry: true})
await client.get("/flaky", {retry: {tries: 5, waitMs: 200, retryableStatuses: [503]}})
// Compose extra rules on top of the default network classifier:
import {defaultRetryableError} from "snapreq/retry"
await client.get("/x", {retry: {shouldRetry: (error) => defaultRetryableError(error) || isMyCase(error)}})Streaming
const response = await client.requestStream({method: "GET", path: "/logs"})
for await (const chunk of response.stream()) {
process.stdout.write(chunk) // chunk is a Uint8Array
}requestStream() requires a transport that supports response streaming and never retries. On Node the raw stream is also available as response.nodeStream.
Request-body compression (Node)
await client.post("/upload", largePayload, {bodyCompression: "gzip"}) // gzip | deflate | br | zstdOver fetch/xhr this raises SnapReqUnsupportedFeatureError.
Capabilities
Each transport advertises what it can do. Inspect them when you need to branch on platform support:
const caps = await client.capabilities()
// {unixSocket, tlsClientCert, requestCompression, responseStreaming, requestStreaming, keepAlive, abort}| Capability | Node | fetch | xhr | | ------------------- | :--: | :---: | :-: | | unixSocket | ✅ | — | — | | tlsClientCert | ✅ | — | — | | requestCompression | ✅ | — | — | | responseStreaming | ✅ | ✅ | — | | requestStreaming | ✅ | — | — | | keepAlive | ✅ | — | — | | abort | ✅ | ✅ | ✅ |
WebSocket
A WebSocket client over globalThis.WebSocket with auto-reconnect, session resumption, request/response calls, channel subscriptions and 1:1 connections.
import SnapReqWebSocketClient from "snapreq/websocket"
const client = new SnapReqWebSocketClient({url: "wss://example.com/websocket"})
const controller = new AbortController()
await client.connect({timeoutMs: 5000, signal: controller.signal})
// Request/response over the socket
const response = await client.post("/things", {name: "thing"}, {timeoutMs: 5000, signal: controller.signal})
response.json()
// Channel subscription
const unsubscribe = await client.subscribeAndWait("updates", {timeoutMs: 5000, signal: controller.signal}, (payload) => console.log(payload))
// 1:1 connection
const connection = client.openConnection("ChatConnection", {timeoutMs: 5000, signal: controller.signal, onMessage: (body) => console.log(body)})
await connection.ready
connection.sendMessage({text: "hi"})timeoutMs and signal are also accepted by get, request, subscribe, subscribeChannel, and waitForReady. WebSocket timeouts intentionally reject with Awaitery's TimeoutError, while WebSocket cancellation rejects with the exact signal.reason; unlike HTTP operations, these errors are not mapped to SnapReq error classes. A failed connect deadline closes only a socket created solely for that operation; a shared in-flight socket remains alive for other connect-dependent operations. Request and readiness failures remove only their own pending entry or handle.
Optional adapters: networkMonitor (gate reconnects on online state), sessionStore (persist the session id across reloads) and deserialize (a (value) => value transform applied inside response.json() so an app can re-hydrate its own wire format).
Session resumption uses only an ID retained from a previous connection or restored from sessionStore, never the ID just assigned to a fresh socket. This remains true when the socket's open event and first session frame arrive together. Every connect() caller waits for session readiness: on reconnect, the new socket's establishment frame alone does not release callers or queued subscriptions before session-resumed or session-gone arrives.
If the socket closes before session readiness, every caller waiting on that readiness generation rejects. A later connection has its own readiness outcome and is not poisoned by the earlier failure.
Hermes Compose development
The repository includes a task-isolated Node 24 development environment for
Hermes/Threadwire work. It mounts one exact worktree into /workspace; the
dependency, npm-cache, and Codex state volumes are scoped by the required
Compose project name. There are no support services, host ports, or fixed
container names. The development-only @kaspernj/hermes-compose package owns
the lifecycle, provider, process and signal handling, bootstrap, proof, smoke
acceptance, and exact cleanup. SnapReq owns only hermes.config.js and its
checked-in Compose/Dockerfile wiring.
From the outer Hermes Docker host, allocate a self-contained Git checkout
(with its own .git directory) directly below
/opt/hermes-dind-shared/worktrees/snapreq and keep its task name identical to
the Compose suffix:
export HERMES_SOURCE_PATH=/opt/hermes-dind-shared/worktrees/snapreq/10575
export HERMES_COMPOSE_PROJECT=snapreq-10575
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose validate
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose config
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose build --pull
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose up
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose exec npm ci
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose proof
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose downCodex authentication is initialized into the task-owned volume from one
explicit, existing Docker volume in the private DinD daemon. That source
volume must contain /auth.json; it is mounted read-only only for the one-off
initializer and never attached to the long-running service:
export HERMES_CODEX_AUTH_VOLUME=<existing-auth-volume>
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose init-codexThreadwire remains on the outer host. The package launches it through its
provider, which runs Codex in the dev service at /workspace:
export THREADWIRE_TARGET=telegram:-1001234567890:42
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose threadwire --prompt 'Inspect the project.'Use the pinned npm exec down command above for ordinary teardown; it
preserves task state. Volume/image deletion requires the separate, exact
confirmation:
HERMES_PURGE_PROJECT="$HERMES_COMPOSE_PROJECT" npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose purgeThe two-stack acceptance entry point is
npm exec --yes --package=@kaspernj/[email protected] -- hermes-compose smoke.
See AGENTS.md for the authoritative boundary, parallel-allocation,
verification, smoke-test, and non-Hermes workflow rules.
License
ISC
