@pyreon/http — API Reference
Generated from
http'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see http.
The request/response layer beneath @pyreon/query. It owns how a request is made — URL building, headers, body encoding, cancellation, typed errors and optional response validation — and deliberately owns no cache, no dedup-by-key and no reactive container, because those already have owners. Mirrors the split the native runtime already made, where PyreonFetch is the reactive result container and PyreonHttp is the request/response layer beneath it. The core has zero dependencies; retry, schema validation, TanStack adapters, mocking and SSR each live behind their own entry so an unused one costs nothing.
Peer dependencies:
@pyreon/validation— install alongside this package.
Multiplatform
Tier: Web-only — the browser package; the native story is stated below
universal web/node HTTP client (WHATWG fetch); the imperative client (api.get/post, custom middleware functions) stays web — native networking is the PyreonFetch/PyreonHttp runtime layer, which applies the lowerable middleware, plus the co-located PyreonStream runtime for SSE / NDJSON
What crosses natively: same-file endpoint calls resolve to PyreonFetch/PyreonQuery — useFetch<T>(getUser({ params: { id: '1' } })) lowers to a native fetch of the templated URL from createHttp({ baseUrl }) + api.endpoint('GET /users/:id'), and useQuery<T>(() => getUser.query({ params: { id: '1' } })) lowers to a cached PyreonQuery (a runtime path param is encoded at request time and keys the cache, so a changed value re-fetches; useFetch needs literal params because it is one-shot; a computed baseUrl stays web); useStream over openEventStream / openNdjsonStream with a same-file endpoint lowers to the native PyreonStream runtime (native/{swift,kotlin}/PyreonStream), byte-parity with this module's parser proven by execution. A call can carry RUNTIME data: a json body (serialized by the web-identical JSON.stringify lowering), header values and query values (serialized at request time by PyreonURL.query, byte-parity with buildQuery proven by execution). An endpoint AWAITED in an async event handler — await signIn({ json: { email: email() } }) — lowers to PyreonHttp.call, which rejects a non-2xx and ends the handler there (reported, never an Android crash); an un-awaited call is sent fire-and-forget. Reading the awaited VALUE is refused by name (no decode type) and pointed at useMutation<T>(ep.mutation()). Uploads lower too: a multipart body becomes the native PyreonMultipartBody (byte-parity with this module's encodeMultipart + FormData proven by execution), a file from @pyreon/hooks' fileFromUri streams from disk as a part or as the raw body, and a form body is encoded by this module's own encodeForm at compile time, or by PyreonURL.query at request time when a value is runtime. A LITERAL createHttp({ use: [...] }) array crosses too: bearer(() => useSession().store.token()), retry({ limit, methods, statuses, network, respectRetryAfter, maxDelay }) and logger({ production }) lower to the native PyreonHttpMiddleware onion that both useFetch and useQuery apply (same order, same backoff, same header case, parity with these middleware proven by execution), and forwardHeaders is the browser no-op; refresh, dedupe, a custom function, a backoff/log callback or a token reading a file-scope signal keep that client's calls web BY NAME, and useStream refuses a middleware client by name
See Multiplatform for the capability matrix and Multiplatform libraries for every package's tier.
Features
Onion middleware
(req, next) => res— retry, auth-refresh and short-circuit are ordinary middleware, which axios interceptor arrays structurally cannot expressImmutable clients:
extend()returns a new instance, so no shared mutable defaults can leak across concurrent SSR requestsThree validation tiers — unchecked cast, any parse function, or any Standard Schema (zod / valibot / arktype /
s/ @pyreon/validation adapters)Endpoints derive the call, the cache key and the response type from one declaration, so queryKey and URL cannot drift
Typed error hierarchy where AbortError stays distinct from a real failure
Timeout ON by default —
fetchhas none, so a hung request otherwise never settlesEvery common body encoding:
json,form(application/x-www-form-urlencoded with OpenAPIstyle/explodeper field — Stripe/Twilio),multipart(files asBlob/File), rawbody, pluscookiesand header records whoseundefinedvalues are omittedPer-request SSR context via AsyncLocalStorage, so concurrent renders never cross cookies
Network-free mocking: middleware short-circuits, so tests need no MSW and no global fetch patch
Complete example
A full, end-to-end usage of the package:
import { createHttp } from '@pyreon/http'
import { bearer, retry } from '@pyreon/http/middleware'
import { standardSchema } from '@pyreon/http/schema'
import { z } from 'zod'
const UserSchema = z.object({ id: z.string(), name: z.string() })
export const api = createHttp({
baseUrl: '/api',
schema: standardSchema,
use: [bearer(() => 'token'), retry({ limit: 2 })],
})
export const listUsers = api.endpoint('GET /users', { response: z.array(UserSchema) })
export const getUser = api.endpoint('GET /users/:id', { response: UserSchema })
const user = await getUser({ params: { id: '1' } })
console.log(user.name)Exports
| Symbol | Kind | Summary |
|---|---|---|
createHttp | function | Create an HTTP client. |
HttpClient | type | A configured client. |
endpoint | function | Declare a reusable endpoint. |
encodeForm | function | The application/x-www-form-urlencoded serializer behind the form request option, exported so a transport or a genera |
HttpMiddleware | type | Onion middleware, chosen over axios-style interceptor arrays because it is the only shape that expresses what people act |
retry | function | Replay a failed request. |
standardSchema | constant | The resolver that enables schema objects in .json(schema) and endpoint({ response }). |
runWithRequest | function | Establish the per-request SSR context, from @pyreon/http/server. |
openEventStream | function | Server-Sent Events over any transport, from @pyreon/http/stream. |
losslessJson | constant | A JSON codec from @pyreon/http/json that keeps 64-bit integers exact. |
RequestError | class | The common base of every error this package throws — catch (e) { if (e instanceof RequestError) … } covers the whole f |
buildUrl | function | The full URL-resolution pipeline createHttp/endpoint build every request URL through: applyPathParams (substitutes |
compose | function | Fold a middleware array (outermost first) over a transport into one callable — what createHttp({ use }) does internall |
createFetchTransport | function | Build a fetch-backed Transport. |
getAmbientRequest | function | Read the inbound request currently in scope — undefined in the browser, and undefined on any server that has not opt |
defineEndpoint | function | The standalone form of client.endpoint(spec, options) — the method is a thin wrapper (`(spec, opts) => defineEndpoint( |
createMock | function | Stub responses as middleware, from @pyreon/http/mock. |
API
createHttp function
(config?: HttpClientConfig) => HttpClientCreate an HTTP client. Every option has a working default: a 30s timeout (because fetch has none and a hung request otherwise never settles), same-origin credentials, and throw-on-non-2xx (because @pyreon/query needs a rejected promise to enter its error state). Clients are IMMUTABLE — there is no defaults object to mutate, since a shared mutable default leaks across concurrent SSR requests. Derive variants with extend(), which accumulates headers and middleware while overriding scalars.
Example
import { createHttp } from '@pyreon/http'
const api = createHttp({ baseUrl: '/api', timeout: 10_000 })
const user = await api.get('/users/:id', { params: { id: '1' } }).json()Common mistakes
Reaching for
api.defaults.headers.common.X = …(axios muscle memory). It does not exist — mutable shared defaults are the classic SSR cross-request leak. Useapi.extend({ headers }), which returns a NEW client.Passing
baseURL(axios spelling). The option isbaseUrl.Expecting
baseUrlto behave likenew URL(path, base). It is a plain PREFIX, so a leading slash does NOT discard the base path.Passing more than one of
json/form/multipart/body. They are mutually exclusive encodings of the same body, and passing two throws rather than silently picking one.Setting
content-type: multipart/form-datayourself alongsidemultipart. The platform writes it WITH the boundary it generated; a hand-set value has no boundary and the server cannot parse the body.Expecting
cookiesto reach the server from a browser.Cookieis a forbidden request header andfetchdrops it silently — in the browser usecredentials: "include";cookiesis for server-side and native callers.Interpolating into the path (
api.get(/users/${id})). That skips URL encoding, so an id containing "/" escapes its segment. Use{ params: { id } }.Expecting retry by default. It is OFF, because it compounds with @pyreon/query’s own retry.
HttpClient type
interface HttpClient { get/post/put/patch/delete/head/options(path, options?): HttpResponsePromise; request(method, path, options?); extend(config): HttpClient; endpoint(spec, options?): Endpoint }A configured client. Each verb returns an HttpResponsePromise — awaitable for the response, or asked directly for a decoded body via .json() / .text() / .blob() / .arrayBuffer() / .formData() / .void(). The request fires eagerly, so .json() never re-issues it and two consumers of the same promise share one network call.
Example
const res = await api.get('/users/1') // HttpResponse (status, headers, raw)
const user = await api.get('/users/1').json() // decoded bodyCommon mistakes
Calling
.json()after already awaiting and reading the body — a Response body is single-use.Assuming
.json()throws on an empty body. A 204/205/304 or an empty 200 resolves toundefined.
endpoint function
<S, V, I = EndpointInput<path>, K = 'json'>(spec: `${HttpMethod} ${string}`, options?: { response?: V; responseType?: K; queryStyle?; formEncoding?; keyScope?; headers?; timeout? }) => Endpoint<S, BodyOf<K, V>, I>Declare a reusable endpoint. One declaration yields the callable, a stable structural cache key, and the response type — which is what stops queryKey and URL from drifting apart, the single biggest pain with axios plus TanStack Query. params is REQUIRED by the type system exactly when the path declares :placeholders, and its keys are extracted from the path literal, so a typo is a compile error. .query(args) emits { queryKey, queryFn } with the AbortSignal already forwarded; .mutation() emits { mutationFn, invalidates }. responseType (text / blob / arrayBuffer / stream / void) decodes non-JSON bodies and types the result accordingly; queryStyle states OpenAPI query serialization per key (form / spaceDelimited / pipeDelimited / tabDelimited / deepObject, explode — tabDelimited is the Swagger 2 collectionFormat: tsv); keyScope namespaces the cache key; validate (strict / warn / off) overrides the client response-validation mode for that one endpoint (a per-request validate option does the same for a single call). The third generic I narrows what a call sends (api.endpoint<S, typeof Schema, { json: NewPet }>(…)) — how a generated client types query and json on direct calls. In a path, \\: is a literal colon (/v1/:name\\:cancel).
Example
const getUser = api.endpoint('GET /users/:id', { response: UserSchema })
await getUser({ params: { id: '1' } })
const options = getUser.query({ params: { id: '1' } })
console.log(options.queryKey)Common mistakes
Hand-writing a
queryKeynext to an endpoint call. Useendpoint.query(...)so the key is derived from the same declaration as the URL.Expecting
mutationFnto receive an AbortSignal. TanStack gives mutations no context at all — pass one in the variables if the mutation must be cancellable.Writing the spec without a method (
"/users"). It must be"<METHOD> <path>".Assuming
invalidatestakes strings. It takes ENDPOINTS, and resolves each to its key prefix.Expecting a per-call
headersto REPLACE the declared ones. They MERGE (per-call wins per key), so a declaredcontent-typesurvives a call that adds an idempotency key.
encodeForm function
(fields: FormFields, encoding?: Record<string, { style?: "form" | "deepObject" | "spaceDelimited" | "pipeDelimited" | "tabDelimited"; explode?: boolean }>) => URLSearchParamsThe application/x-www-form-urlencoded serializer behind the form request option, exported so a transport or a generated client can produce byte-identical bodies. Follows OpenAPI's Encoding Object: the default is form + explode (arrays repeat the key, an object spreads its properties), deepObject uses brackets recursively with indexed arrays (items[0][price]=…, the shape Stripe declares), and spaceDelimited/pipeDelimited/tabDelimited join arrays. null/undefined entries are dropped rather than sent as text. Siblings encodeMultipart (FormData; Blob values become file parts, objects become JSON text parts) and encodeCookies (a Cookie header value) cover the other encodings.
Example
import { encodeForm } from '@pyreon/http'
const body = encodeForm(
{ amount: 2000, metadata: { order: 'A1' } },
{ metadata: { style: 'deepObject', explode: true } },
)
body.toString() // "amount=2000&metadata%5Border%5D=A1"Common mistakes
Expecting a nested object under the DEFAULT style to keep its nesting. OpenAPI's
formstyle spreads one level; declaredeepObjectfor nested fields (a deeper value falls back to brackets rather than[object Object]).Building the body with
new URLSearchParams(obj)instead. That stringifies nested values to[object Object]and sendsnullas the text "null".
HttpMiddleware type
(request: HttpRequest, next: Next) => Promise<HttpResponse>Onion middleware, chosen over axios-style interceptor arrays because it is the only shape that expresses what people actually need: retry calls next() in a LOOP (an interceptor pair cannot re-enter the chain, which is why axios users end up hanging config.__isRetry flags off the request), refresh inspects the response and re-issues, and a mock or cache returns WITHOUT calling next. Order is lexical — the use: [...] array — so there is no registration registry and no eject handle to leak.
Example
const logger: HttpMiddleware = async (request, next) => {
const started = Date.now()
const response = await next(request)
console.warn(request.method, request.url, Date.now() - started)
return response
}Common mistakes
Forgetting to return the response from
next(). The chain resolves to whatever each middleware returns.Assuming
next()may only be called once. Calling it repeatedly is exactly what makes retry possible.Mutating
request.headersexpecting it to affect an already-issued attempt — mutate before callingnext().
retry function
(options?: RetryOptions) => HttpMiddlewareReplay a failed request. OFF unless you add it, because @pyreon/query already retries and the two compound silently. Retries idempotent methods only (POST is excluded), on 408/413/429/5xx plus transport failures, with exponential backoff, full jitter and Retry-After support. Never replays a cancellation, and stops replaying the moment the caller aborts — including mid-backoff.
Example
const api = createHttp({ use: [retry({ limit: 3 })] })Common mistakes
Enabling it alongside @pyreon/query’s own retry without lowering one of them — 3 × 3 is nine requests.
Adding
POSTtomethodswithout making the endpoint idempotent. A replayed POST can double-charge.Expecting an aborted request to be retried. Cancellation is the expected outcome of navigating away, not a failure.
standardSchema constant
SchemaResolverThe resolver that enables schema objects in .json(schema) and endpoint({ response }). Imported from @pyreon/http/schema and passed as createHttp({ schema: standardSchema }). Handles both @pyreon/validation typed adapters (zodSchema(...), which carry _infer and no ~standard) and raw Standard Schema instances (zod, valibot, arktype, @pyreon/validate’s s). It lives behind its own entry so the core never imports a validation library — a runtime check inside the core would keep it in every bundle, because tree-shaking works on reachability, not runtime branches.
Example
import { standardSchema } from '@pyreon/http/schema'
const api = createHttp({ schema: standardSchema })Common mistakes
Passing a schema object without configuring the resolver. The error explains the fix, but the plain parse-function form (
.json(MySchema.parse)) needs no resolver at all.Passing an async schema. Response validation is synchronous, and an async one is rejected loudly rather than resolving a Promise as data.
Assuming
validate: "off"is a free performance win. It is only safe for non-transforming schemas.
runWithRequest function
<T>(request: AmbientRequest, fn: () => T) => TEstablish the per-request SSR context, from @pyreon/http/server. Inside it a relative URL resolves against the inbound origin (on the server it otherwise has no origin and fetch rejects) and forwardHeaders can copy cookies through. Backed by AsyncLocalStorage rather than a module-level variable, so concurrent renders never see each other’s request — the naive shared slot forwards one user’s session cookie into another user’s render. This is the only entry that imports node:async_hooks, keeping it out of every browser bundle.
Example
import { runWithRequest } from '@pyreon/http/server'
export const middleware = (ctx: { req: Request }) =>
runWithRequest(ctx.req, () => api.get('/users').json())Common mistakes
Importing it from
@pyreon/httpinstead of@pyreon/http/server— the split is what keeps node:async_hooks out of the client bundle.Expecting relative URLs to resolve on the server WITHOUT it. There is no ambient origin until you wire it.
Assuming headers forward automatically.
forwardHeadersrequires an explicit allowlist and stops at the origin boundary by default.
openEventStream function
<T>(connect: (ctx: StreamContext) => Promise<ReadableStream<Uint8Array> | null | undefined>, options?: EventStreamOptions<T>) => EventStream<SseEvent<T>>Server-Sent Events over any transport, from @pyreon/http/stream. connect(ctx) opens the body — an endpoint declared with responseType: 'stream', a raw fetch, an axios/ky client — and receives an AbortSignal, the headers the stream needs (accept, last-event-id when resuming) and the attempt number. The result is an async iterable of { type, data, id } with data JSON-parsed (or data: 'text') and run through parse. A dropped connection, 408, 429 or 5xx is retried with exponential backoff resuming from the last id; a server retry: sets the delay even in a control-only block; blank-line-terminated id-only blocks update the resume id, and an empty id clears it; filtered valid events reset the failure budget; a no-body response (204) always ends the stream; other 4xx are final; reconnect: { onEnd: true } resumes after a clean end the way EventSource does. break, close() or options.signal cancel the owned body reader; close/abort settle pending iteration even when a transport or async parser ignores cancellation, cancel late response bodies, and release external listeners before iteration too. Wire line scanning is linear in decoded input length, including large rows split across chunks. openNdjsonStream is the NDJSON sibling (no reconnection — there is no resume id); readEventStream / readNdjson are the bare WHATWG-grammar parsers. Consumed through useStream with a same-file endpoint it also runs on iOS and Android (the native PyreonStream runtime); one disclosed native difference: URLSession reports a chunked body cut off by a graceful close as a clean end, so the Swift runtime reads an event left half-built at EOF as a dropped connection, and a cut landing exactly on an event boundary reads as a clean end there.
Example
import { openEventStream } from '@pyreon/http/stream'
const tail = api.endpoint('GET /logs/tail', { responseType: 'stream' })
for await (const ev of openEventStream((ctx) => tail({ signal: ctx.signal, headers: ctx.headers }), {
parse: (v) => LogLine.parse(v),
})) {
if (ev.data.level === 'fatal') break
}Common mistakes
Not merging
ctx.headersinto the request — without them the server gets noLast-Event-IDon a reconnect and replays from the start.streamHeaders(callHeaders, ctx.headers)merges any header shape.Turning on
reconnect: { onEnd: true }for a request/response stream (an LLM completion) — a clean end means "done", and resuming re-sends the request.Expecting NDJSON to reconnect — it has no event id, so a retry would duplicate everything already received; a failure ends the stream with the error.
Iterating the same stream twice — it is single-use; call the function again for a new request.
losslessJson constant
const losslessJson: JsonCodec // { parse: parseJsonLossless, stringify: stringifyJsonLossless }A JSON codec from @pyreon/http/json that keeps 64-bit integers exact. JSON.parse rounds an integer past 2^53 - 1 before any schema sees it (9007199254740993 arrives as 9007199254740992); pass createHttp({ json: losslessJson }) and such an integer decodes as a bigint, while a bigint in a request json body is written as JSON NUMBER text instead of throwing. Everything else is byte-for-byte JSON.parse / JSON.stringify — a safe integer, a fraction and an exponent form all stay numbers. The client routes response bodies, error bodies and request bodies through it; an extended client inherits it; a bigint is also accepted as a path / query / header / cookie parameter (written as its exact digits, and as digits in the query KEY, since JSON.stringify — and so the TanStack key hash — throws on a bigint); streams take parseJson: parseJsonLossless. Digits are read from the source text: plain JSON.parse when no 16-digit run exists, the reviver context.source where the engine passes it, an own strict parser elsewhere (same SyntaxErrors as JSON.parse).
Example
import { createHttp } from '@pyreon/http'
import { losslessJson } from '@pyreon/http/json'
const api = createHttp({ baseUrl: '/api', json: losslessJson })
const order = await api.get('/orders/1').json<{ id: bigint | number }>()
await api.post('/orders', { json: { id: 9007199254740993n } })Common mistakes
Expecting EVERY integer to become a bigint — only one a double cannot hold exactly does; a small id stays a number, so a field typed
bigintneeds a schema that widens it (@pyreon/latheint64: 'bigint'emits one).Parsing the body yourself with
JSON.parse(await res.text()then parse) — the rounding happens there, before the codec can help.Turning validation
offon a field that relies on a widening schema — small values then stay numbers under a bigint type.
RequestError class
class RequestError extends Error { readonly request: HttpRequest | undefined } — subclasses: HttpError (+ ClientError/ServerError), TimeoutError, AbortError, NetworkError, ParseError, ResponseValidationErrorThe common base of every error this package throws — catch (e) { if (e instanceof RequestError) … } covers the whole family in one check, without listing members. Every message is [Pyreon]-prefixed. The subclasses each name a distinct failure MODE, not just a status code: HttpError (and its ClientError/ServerError refinements for 4xx/5xx) is a non-2xx response — thrown by default because @pyreon/query needs a rejected promise to enter its error state; TimeoutError is the request exceeding its timeout; NetworkError is the transport failing before any response arrived (DNS, offline, CORS); ParseError is a body that did not decode as the requested type; ResponseValidationError is a body that decoded but failed schema validation (its .value carries the raw, unvalidated body for reporting). AbortError is the odd one out — see its own mistake below.
Example
try {
await getUser({ params: { id } })
} catch (e) {
if (e instanceof AbortError) return // cancellation — not a failure
if (e instanceof RequestError) reportError(e) // every other failure mode
throw e
}Common mistakes
Reporting
AbortErroras a failure — it is the EXPECTED outcome of navigating away mid-request or a newer call superseding an older one; check for it FIRST and return, never log it to an error trackerChecking
e instanceof AbortErroralone to detect cancellation —fetchitself can reject with a plainDOMException{name:"AbortError"}(not this package's class); use the standaloneisAbortError(e)function, which recognizes bothAssuming every non-2xx throws — a client created with
throwHttpErrors: falsereturns the response instead; checkresponse.okin that modeReading
ResponseValidationError.requestfor the parsed value — that field is the outgoing request; the raw (unvalidated) body is on.value
See also: createHttp · standardSchema
buildUrl function
(baseUrl: string | undefined, path: string, params: PathParams | undefined, query: QueryParams | undefined) => stringThe full URL-resolution pipeline createHttp/endpoint build every request URL through: applyPathParams (substitutes :name placeholders, encodeURIComponent-encoded so an id containing / cannot escape its segment — throws on a missing param rather than leaving a literal :id in the URL) → joinUrl (base + path with exactly one slash between them; a PREFIX join, not new URL(path, base) — see the "baseUrl is a PREFIX" gotcha) → buildQuery (serializes a query object, DROPPING undefined/null entries so they never land in the URL as the literal text "undefined", and repeating the key for array values). Each step is also individually exported (applyPathParams, joinUrl, buildQuery, isAbsoluteUrl) for anything building URLs outside the client — a custom transport, a test assertion, a devtools panel.
Example
buildUrl('/api', '/users/:id', { id: '1' }, { includeDeleted: true })
// -> '/api/users/1?includeDeleted=true'Common mistakes
Interpolating a param into the path string yourself (
`/users/${id}`) instead of:id+params— that skips encoding, so an id containing/or?escapes its segmentExpecting
joinUrlto follow WHATWGnew URL(path, base)semantics — a leading slash does NOT discard the base path here;joinUrl('/api/v1', '/users')is/api/v1/users, never/usersPassing
undefinedin a query object and expecting it omitted only sometimes — it is ALWAYS dropped, on every value including array entries
See also: createHttp
compose function
(middleware: readonly HttpMiddleware[], transport: Transport) => TransportFold a middleware array (outermost first) over a transport into one callable — what createHttp({ use }) does internally to build the client's dispatch chain. Exposed standalone for testing a middleware pipeline directly (no need to build a full client), or for composing a custom Transport outside the normal client shape. Deliberately has NO "next() called multiple times" guard (unlike Koa) — retry middleware legitimately re-enters the downstream chain in a loop, and a guard would forbid exactly that.
Example
import { compose } from '@pyreon/http'
import { retry } from '@pyreon/http/middleware'
import { fetchTransport } from '@pyreon/http'
const dispatch = compose([retry({ limit: 2 })], fetchTransport)
const response = await dispatch(request)Common mistakes
Assuming a middleware may only call
next()once — repeated calls are exactly what makes retry possible; do not add a re-entrancy guard on topForgetting a middleware must RETURN the response from
next()— the chain resolves to whatever each middleware returns, so swallowing it silently drops the response
See also: HttpMiddleware · retry
createFetchTransport function
(fetchImpl?: typeof fetch) => TransportBuild a fetch-backed Transport. fetchTransport (a constant) is the default instance — createHttp() uses it when no custom transport is configured. createFetchTransport(fetchImpl) lets you inject a substitute fetch (tests, SSR, a future in-process dispatcher) — the same injectable-implementation seam @pyreon/zero-content's search runtime uses. Its whole job beyond calling fetch is normalising the rejection channel: a network failure rejects with a bare TypeError and cancellation rejects with DOMException{name:"AbortError"}, and this transport turns those into NetworkError/AbortError respectively so they never get conflated downstream.
Example
import { createFetchTransport } from '@pyreon/http'
const api = createHttp({ transport: createFetchTransport(myFetchImpl) })Common mistakes
Expecting a raw
TypeError/DOMExceptionfrom a client built on this transport — both are already normalized toNetworkError/AbortErrorBuilding this for every request instead of once at client-construction time — it is a factory, meant to be called once and reused
See also: createHttp · RequestError
getAmbientRequest function
() => AmbientRequest | undefinedRead the inbound request currently in scope — undefined in the browser, and undefined on any server that has not opted in via runWithRequest (from @pyreon/http/server). resolveAgainstAmbientOrigin(url) is the companion that USES it: a root-relative URL (/api/users) has no origin on the server, so it resolves against the ambient request's origin; an already-absolute URL, or one with no ambient request, is returned unchanged (never throwing — a malformed inbound URL degrades gracefully instead of failing the render). Both are client-safe (no node:async_hooks import) — the AsyncLocalStorage wiring itself lives in @pyreon/http/server's runWithRequest, kept in a separate entry so importing this one never drags a Node-only module into a browser bundle.
Example
import { getAmbientRequest, resolveAgainstAmbientOrigin } from '@pyreon/http'
const req = getAmbientRequest() // undefined in the browser
const url = resolveAgainstAmbientOrigin('/api/users') // absolute on the server, unchanged in the browserCommon mistakes
Reading this without ever calling
runWithRequest(from@pyreon/http/server) somewhere upstream — it always returnsundefineduntil something establishes the context, so relative URLs on the server never resolve on their ownImporting
runWithRequestfrom@pyreon/http— it lives in@pyreon/http/serverspecifically sonode:async_hooksstays out of the client bundle; this read-side pair is the client-safe half
See also: runWithRequest · createHttp
defineEndpoint function
(client: HttpClient, spec: `${HttpMethod} ${string}`, options?: { response?: Validator }) => EndpointThe standalone form of client.endpoint(spec, options) — the method is a thin wrapper ((spec, opts) => defineEndpoint(client, spec, opts)). Reach for this directly when defining endpoints in a module that should not import a specific client instance (a shared endpoints file consumed against different clients per environment), or when building tooling that generates endpoint declarations. Same key/params/response semantics as endpoint.
Example
import { defineEndpoint } from '@pyreon/http'
const getUser = defineEndpoint(api, 'GET /users/:id', { response: UserSchema })Common mistakes
Using this when
api.endpoint(...)reads more naturally — for the common case of one client per module, prefer the method form; reach for the standalone function only when the client is not fixed at declaration time
See also: endpoint · createHttp
createMock function
(routes: readonly MockRoute[]) => MockHandleStub responses as middleware, from @pyreon/http/mock. Because middleware can short-circuit, mocking needs no MSW, no service worker and no global fetch patch — so it cannot leak between test files the way a patched global does. Returns the middleware plus the recorded calls for assertions. A request matching no route falls through to the next layer, so you can stub a couple of endpoints and let the rest hit a real transport.
Example
import { createMock } from '@pyreon/http/mock'
const handle = createMock([{ path: '/users/1', json: { id: '1' } }])
const api = createHttp({ use: [handle.middleware] })
await api.get('/users/1').json()Common mistakes
Expecting a string
pathto match the full URL. It matches by suffix, sobaseUrlneed not be repeated.Forgetting
handle.reset()between tests when asserting on call counts.
Package-level notes
Retry is OFF by default: Deliberate. @pyreon/query already retries 3×, so a client-side default of 3 turns one logical query into NINE requests with nothing in devtools to explain it. Opt in with
use: [retry()]and keep the total visible in one place.
Schema objects need a resolver: The core never imports a validation library. Pass
schema: standardSchemafrom@pyreon/http/schemato enable.json(mySchema). Without it, only plain(raw) => Tparse functions work — which is what keeps the core at zero dependencies.
validate: "off"is unsafe for transforming schemas: A coercing schema (z.coerce.number(), a.transform()) does real work, so skipping it changes the VALUE and the declared type then lies. Prefervalidate: "warn", which logs and passes the raw body through.
baseUrl is a PREFIX, not a WHATWG base:
'https://api.com/v1' + '/users'is…/v1/users, never…/users. The WHATWG rule (a leading slash discards the base path) makes the same path behave differently under a relative vs absolute base — an environment-dependent surprise that only shows up in production.