pyreon

@pyreon/http owns how a request is made: URL building, path params, query encoding, headers, body, cancellation, typed errors, and optional response validation.

It deliberately owns no cache, no dedup-by-key, and no reactive container — @pyreon/query, useFetch and createResource already do. That split mirrors the one the native runtime already made, where PyreonFetch is the reactive result container and PyreonHttp the request/response layer beneath it.

@pyreon/httpstable

Installation

pyreon add @pyreon/http

Quick start

// @check
import { createHttp } from '@pyreon/http'

interface User {
  id: string
  name: string
}

export const api = createHttp({ baseUrl: '/api' })

const user = await api.get('/users/:id', { params: { id: '1' } }).json<User>()

Every verb returns a promise you can either await for the response, or ask directly for a decoded body:

const res  = await api.get('/users/1')        // HttpResponse — status, headers, raw
const user = await api.get('/users/1').json() // decoded body

.text(), .blob(), .arrayBuffer(), .formData() and .void() work the same way. The request fires eagerly, so .json() never re-issues it.

Request bodies

One option per encoding; they are mutually exclusive, and passing two throws.

api.post('/users', { json: { name: 'Ada' } })               // application/json
api.post('/v1/customers', {                                  // x-www-form-urlencoded
  form: { email: 'a@b.c', metadata: { plan: 'pro' } },
  formEncoding: { metadata: { style: 'deepObject', explode: true } },
})                                                           // email=a%40b.c&metadata%5Bplan%5D=pro
api.post('/files', { multipart: { file, purpose: 'x' } })   // FormData; Blob/File = file part
api.post('/raw', { body: bytes, headers: { 'content-type': 'application/octet-stream' } })

form follows OpenAPI's Encoding Object per field: the default form + explode repeats an array's key, deepObject writes brackets (items[0][price]=…, what Stripe declares), spaceDelimited / pipeDelimited join arrays. null / undefined fields are dropped, never sent as text. encodeForm, encodeMultipart and encodeCookies are exported for transports that need the same bytes.

A header record may carry numbers, booleans and undefined — an undefined header is omitted rather than sent as "undefined". cookies: { session } writes a Cookie header; a browser drops that header silently (it is forbidden from script), so there use credentials: 'include'. On an endpoint, declared headers and per-call headers MERGE, and formEncoding is declared once:

const createCustomer = api.endpoint('POST /v1/customers', {
  formEncoding: { metadata: { style: 'deepObject', explode: true } },
})
await createCustomer({ form: { metadata: { plan: 'pro' } }, headers: { 'idempotency-key': key } })

Everything is optional

The core has zero dependencies. Each capability is a separate entry, so an unused one costs nothing.

LayerEntry
client, errors, endpoints@pyreon/http
retry / dedupe / auth / logging / header forwarding@pyreon/http/middleware
Standard Schema validation@pyreon/http/schema
TanStack adapters@pyreon/http/query
network-free mocking@pyreon/http/mock
per-request SSR context@pyreon/http/server
lossless 64-bit JSON codec@pyreon/http/json
SSE / NDJSON streaming@pyreon/http/stream

/server is the only entry that touches node:async_hooks, so it stays out of every browser bundle by construction.

Always use params — never interpolate

api.get(`/users/${id}`)                   // ✗ no encoding
api.get('/users/:id', { params: { id } }) // ✓ encoded

Interpolating skips URL encoding. An id of 1/../admin escapes its segment and the request silently reaches /admin — the URL-shaped sibling of SQL string concatenation. params runs every value through encodeURIComponent.

The pyreon/no-unencoded-path-interpolation lint rule catches this.

Validation is three tiers

Only the third costs a dependency.

await api.get('/users/1').json<User>()     // 0 — unchecked cast, zero cost
await api.get('/users/1').json(isUser)     // 1 — any (raw: unknown) => T
await api.get('/users/1').json(UserSchema) // 2 — needs a schema resolver

Tier 1 already covers a lot: a hand-written type guard, a superstruct assert, or a detached zodSchema.parse all fit (raw: unknown) => T.

Tier 2 accepts any Standard Schema — zod, valibot, arktype, @pyreon/validate's s, and @pyreon/validation's typed adapters — and is enabled per client:

import { standardSchema } from '@pyreon/http/schema'

export const api = createHttp({ baseUrl: '/api', schema: standardSchema })

A mismatch throws ResponseValidationError. Two other modes:

  • validate: 'warn' — log and pass the raw body through. Useful when a backend drifts and you would rather degrade than white-screen. It fires in production too; that is the point.

  • validate: 'off' — skip validation. Safe only for non-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.

The mode is set on the client and can be overridden for one endpoint or one call — the more specific setting wins:

const events = api.endpoint('GET /events', { response: EventList, validate: 'off' }) // this endpoint only
await api.get('/users/1', { validate: 'warn' }).json(User)                          // this request only

Lossless JSON

JSON.parse turns every number into a double, so an integer past 2^53 − 1 (9007199254740991) is rounded before any schema sees it — 9007199254740993arrives as 9007199254740992. For a 64-bit id that is a different record.

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 } }) // sent as the number 9007199254740993

losslessJson decodes an integer that a double cannot hold exactly as abigint, and encodes a bigint back as JSON number text (JSON.stringifythrows on one). Everything else is exactly JSON.parse / JSON.stringify: a safe integer stays a number, and so does a fraction or an exponent form — the server wrote a double. Response bodies, error bodies (HttpError.body) and request json bodies all go through the client's codec; an extended client inherits it. Streams take the decoder as parseJson: parseJsonLossless, and@pyreon/http/mock writes a bigint fixture as a number.

The digits are recovered from the source text, portably: plain JSON.parsewhen no 16-digit run is present (the common body — no unsafe integer is possible), the reviver's context.source where the engine passes it (detected at first use; verified on Node 26 and Bun 1.4), and an own strict parser everywhere else (older engines, Hermes) — which rejects exactly what JSON.parse rejects. Because only an unsafe integer becomes a bigint, a field that is ALWAYS a bigint needs a schema that widens the rest; @pyreon/lathe's int64: 'bigint' generates exactly that.

createHttp({ json }) takes any { parse, stringify } codec.

Endpoints

The biggest real pain with axios + TanStack Query is that the queryKey and the URL drift apart, and the response type is a cast. An endpoint derives all three from one declaration:

export const listUsers  = api.endpoint('GET /users',     { response: UserSchema.array() })
export const getUser    = api.endpoint('GET /users/:id', { response: UserSchema })
export const createUser = api.endpoint('POST /users',    { response: UserSchema })

await getUser({ params: { id: '1' } })                    // typed + validated
useQuery(() => getUser.query({ params: { id: id() } }))   // key + fn + signal
useMutation(createUser.mutation({ invalidates: [listUsers] }))

params is required by the type system exactly when the path declares :placeholders, and its keys come from the path literal — so { params: { userId } } against /users/:id is a compile error.

Endpoints — mocked GET + POST, schema-validated

The live example above uses @pyreon/http/mock (see "Testing" below) so the page never makes a real network call — the endpoint declaration, the schema validation, and the <For>-rendered list are all real.

invalidates takes endpoints, not stringly-typed keys.

Streaming — Server-Sent Events and NDJSON

@pyreon/http/stream reads the wire format from any ReadableStream<Uint8Array>— an endpoint declared with responseType: 'stream', a raw fetch, a generated axios or ky client — so a stream can be a POST, carry auth headers, and go through your middleware and mock transport. EventSource can do none of that.

import { openEventStream, openNdjsonStream } from '@pyreon/http/stream'

const tail = api.endpoint('GET /logs/tail', { responseType: 'stream' })

const stream = openEventStream((ctx) => tail({ signal: ctx.signal, headers: ctx.headers }), {
  parse: (v) => LogLine.parse(v),      // validate each event's JSON `data`
  events: ['line'],                    // only these `event:` types
})
for await (const ev of stream) {
  console.log(ev.type, ev.data, ev.id)
  if (ev.data.done) break              // closes the connection
}
stream.lastEventId()                   // persist it to resume later
  • connect(ctx) is called for every (re)connection with an AbortSignal, the headers the stream needs (accept, and last-event-id when resuming) and the attempt number. Merge ctx.headers into the request —streamHeaders(callHeaders, ctx.headers) does that for any header shape.

  • Reconnection (SSE): a dropped connection, a 408 / 429 or a 5xx is retried with exponential backoff (reconnect: { attempts: 5, delay: 1000, maxDelay: 30000 }), resuming with Last-Event-ID; a server retry: sets the delay, any other 4xx is final, and the attempt budget resets once an event arrives. A clean end finishes the stream unless reconnect: { onEnd: true } (whatEventSource does — wrong for a request/response stream like an LLM completion). reconnect: false turns it off.

  • NDJSON yields one parsed value per line; blank lines are skipped, a final line without a newline still counts, and a bad line throws aStreamParseError naming its line number. It never reconnects — there is no resume id, so a retry would replay.

  • Cancellation: break, stream.close() or options.signal abort the request and cancel the body, so the server sees the socket close.

  • data: 'text' keeps each SSE event's data as a string instead of JSON.

  • readEventStream(body) / readNdjson(body) are the bare parsers, for a stream you manage yourself. They follow the WHATWG grammar: CR / LF / CRLF split anywhere across chunks, multi-byte characters split across chunks, multi-line data, comments, a leading BOM, and an unterminated final event discarded.

To mock a stream, @pyreon/http/mock routes take accept (match only a request whose Accept names that media type — one URL answering JSON to a plain call and a stream to a streaming one) and a computed body:

createMock([
  {
    path: '/chat',
    accept: 'text/event-stream',
    headers: { 'content-type': 'text/event-stream' },
    // resume after the id the client sends, like a real server
    body: (call) => events.slice(Number(call.headers['last-event-id'] ?? 0)).join(''),
  },
  { path: '/chat', json: { text: 'whole' } },   // the non-stream call
])

For a component, useStream from @pyreon/query turns any of these into signals, and a Lathe client generates typed <op>Stream /use<Op>Stream pairs for every streaming operation in a spec.

Middleware

type HttpMiddleware = (request: HttpRequest, next: Next) => Promise<HttpResponse>

Onion middleware rather than axios-style interceptor arrays, because it is the only shape that expresses what people actually need:

  • retry — call 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 — inspect the response, refresh, re-issue.

  • short-circuit — return without calling next (mock, cache, offline).

Order is lexical (the use: [...] array), so there is no registration registry and no eject() handle to leak.

import { bearer, retry } from '@pyreon/http/middleware'

const api = createHttp({
  baseUrl: '/api',
  use: [bearer(() => session().token), retry({ limit: 2 })],
})

Defaults, and why

ConcernDefaultWhy
timeouton, 30sfetch has none — a server that accepts and never responds wedges the promise forever
retryoff@pyreon/query already retries 3×; a client default of 3 makes one logical query nine requests
dedupeoffquery already dedupes by key
throw on non-2xxonquery needs a rejected promise to enter its error state
credentialssame-origin—

Errors

RequestError                  base — catch this to cover everything
├── HttpError                 non-2xx (carries .status, .response, the decoded .body, .matched)
│   ├── ClientError           4xx
│   └── ServerError           5xx
├── TimeoutError              exceeded `timeout`
├── AbortError                cancelled — never retry, never report
├── NetworkError              failed before a response
├── ParseError                body did not decode
└── ResponseValidationError   body did not match the schema

AbortError is kept deliberately distinct: "the user navigated away" and "the API is down" demand opposite handling.

An HttpError carries its decoded body (JSON when it parses, the text otherwise; read from a clone, so response.raw stays readable). Declare errors on an endpoint to validate and TYPE it by status:

import type { EndpointError } from '@pyreon/http'

const getUser = api.endpoint('GET /users/:id', {
  response: User,
  errors: { 404: NotFound, '5XX': Outage, default: Problem },
})

getUser({ params: { id } }).catch((err: EndpointError<typeof getUser>) => {
  if (err.matched === '404') console.log(err.body.message) // NotFound
})

The most specific key wins (exact, then range, then default), under the client's validate mode. A body that fails its schema is still the same HttpError, with matched undefined and the raw body — the HTTP failure is what the caller needs to see. Network failures and timeouts have no matched, so the union narrows cleanly.

import { HttpError } from '@pyreon/http'

try {
  await getUser({ params: { id: '404' } })
} catch (error) {
  if (error instanceof HttpError && error.status === 404) {
    // error.response.raw is the real Response — the body is still readable
  }
}

Clients are immutable

There is deliberately no api.defaults.headers.common.X = …. A mutable shared default leaks across concurrent SSR requests — one request's mutation reaching another user's render. Derive instead:

const authed = api.extend({ headers: { Authorization: `Bearer ${token}` } })

extend() accumulates headers and middleware and overrides scalars, leaving the parent untouched.

SSR

On the server a relative URL has no origin and fetch rejects, and forwarding auth needs the inbound headers. Wire it once per request:

import { runWithRequest } from '@pyreon/http/server'
import { forwardHeaders } from '@pyreon/http/middleware'

export const api = createHttp({ baseUrl: '/api', use: [forwardHeaders(['cookie'])] })

// in your handler / middleware
runWithRequest(ctx.req, () => render(ctx))

Backed by AsyncLocalStorage, not a module-level variable — concurrent renders each see only their own request. (A shared slot forwards one user's session cookie into another user's render.)

forwardHeaders requires an explicit allowlist and stops at the origin boundary by default, so pointing baseUrl at a third party cannot leak cookies there. Pass { crossOrigin: true } only when the other origin is yours.

Testing

Because middleware can short-circuit, mocking needs no MSW and no globalThis.fetch patch — so it cannot leak between test files:

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()
expect(handle.calls).toHaveLength(1)

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.

Lower-level exports

The client covers the common path; these are the building blocks underneath it.

ExportEntryDescription
defineEndpoint(client, spec, options?)@pyreon/httpStandalone form of api.endpoint(spec, options).
compose(middleware, transport)@pyreon/httpFold a middleware list over a transport into one Transport.
createFetchTransport(fetchImpl?) / fetchTransport / toHttpResponse(raw, request)@pyreon/httpThe default fetch-backed transport, a factory for a custom fetch, and the Response → HttpResponse adapter.
buildUrl(baseUrl, path, params, query) / joinUrl / applyPathParams / buildQuery / isAbsoluteUrl@pyreon/httpThe URL helpers the client uses (encoding included).
isAbortError(value)@pyreon/httpTrue for the client's AbortError and for the platform DOMException named AbortError that fetch rejects with.
getAmbientRequest() / resolveAgainstAmbientOrigin(url)@pyreon/httpRead the in-scope SSR request, and resolve a relative URL against its origin.
bearer / refresh / dedupe / logger / retry / forwardHeaders@pyreon/http/middlewareThe shipped middleware (see Middleware above).
toQueryOptions(client, path, options?)@pyreon/http/query{ queryKey, queryFn } for an ad-hoc GET without declaring an endpoint.
runWithRequest(request, fn) / getRequest()@pyreon/http/serverRun code with an ambient request, and read it back.
isRetryableStreamError(error)@pyreon/http/streamThe default shouldRetry of a stream's reconnect policy.

Multiplatform

The client itself is web/Node only — the transport and middleware run on fetch. On iOS and Android (PMTC), same-file endpoint calls lower to the native networking runtime instead: useFetch<T>(getUser({ params: { id: '1' } })) becomes a PyreonFetch, useQuery<T>(() => getUser.query({ params: { id: '1' } })) a cached PyreonQuery, and useStream over openEventStream / openNdjsonStream with a same-file endpoint a PyreonStream. A computed baseUrl stays web.

The client's middleware crosses too, when the use array is a literal the compiler can read:

const useSession = defineStore('session', () => ({ token: signal('') }))

const api = createHttp({
  baseUrl: 'https://api.example.com',
  use: [bearer(() => useSession().store.token()), retry({ limit: 3 }), logger()],
})

bearer, retry and logger lower to the native PyreonHttpMiddleware onion that the useFetch and useQuery harnesses apply around every request, in array order, with the web semantics: the same backoff (exponential, full jitter, capped at 20 s, then maxDelay), Retry-After in seconds or as an HTTP date, the idempotent-methods default, a nil or empty token sending no header, the baseUrl origin boundary, and a logger line that is silent outside a debug build unless production: true. A differential test runs the web middleware and both native runtimes on the same inputs and requires identical attempts, headers, waits and log lines. forwardHeaders lowers to nothing — on a client there is no inbound request to forward from, exactly as in a browser.

What keeps a client's calls on the web, each with a warning naming the entry: refresh, dedupe, a custom middleware function, a backoff or log callback, a non-literal option, a use that is not an array literal, and a bearer token that reads a file-scope signal() (which does not exist natively — keep the token in a store). useStream, an awaited endpoint call (PyreonHttp.call) and useMutation refuse a client with middleware by name rather than sending a request without it. Two native differences are disclosed: on Android, logger() cannot see the app's BuildConfig, so it logs unless you set PyreonHttpPipelineEnv.debugBuild = BuildConfig.DEBUG at startup; and a request body is always replayable natively, so the web's one-shot-stream exception to retry does not arise.

Sending what the user entered

A request can carry runtime values, and they are read at the moment the web reads them:

const api = createHttp({ baseUrl: 'https://example.com/api' })
const signIn = api.endpoint('POST /session')
const saveProfile = api.endpoint('PUT /profile/:id')

function SignIn() {
  const email = signal('')
  const save = useMutation<Profile>(saveProfile.mutation())

  const submit = async () => {
    // Lowers to PyreonHttp.call: a non-2xx rejects and ends the handler here.
    await signIn({ json: { email: email() }, headers: { 'x-client': 'app' } })
    save.mutate({ params: { id: 'me' }, json: { email: email() } })
  }
  // …
}
  • json is serialized by the web-identical JSON.stringify lowering (key order, number and string formatting all match), and content-type: application/json is added unless you set one.

  • Runtime header and query values go out as the web would send them — the query through PyreonURL.query, which mirrors buildQuery byte for byte (form-encoding, an array repeating its key, null / undefined dropped). Header values must be strings; a nullable one is refused with the fix.

  • An awaited endpoint call in an async event handler lowers to PyreonHttp.call; a failure ends the handler and is reported (logged), never crashing the app. An un-awaited call is sent fire-and-forget. Reading the awaited value (const user = await signIn(…)) has no decode type natively and is refused by name — use useMutation<T>(ep.mutation()) and await save.mutateAsync(…).

  • useMutation builds each request at the mutate / mutateAsync call site. The latest call owns data / error / status; onSuccess / onError / onSettled run in TanStack's order at both layers. When the view goes away nothing is written and no callback runs, but the request itself is not aborted (a write the server may already have committed is never torn mid-flight). invalidates, onMutate and retry are named in a warning rather than lowered.

  • Known divergence: natively an awaited call does not decode the response, so a 2xx whose body is not JSON resolves; the web's .json() rejects it.

Uploading a picked file

A picker or the camera hands back a URI — a handle, not the file. fileFromUri from @pyreon/hooks is the step in between, and the same source sends the same bytes on every platform:

import { fileFromUri, useImagePicker } from '@pyreon/hooks'

const uploadAvatar = api.endpoint('POST /me/avatar')

function AvatarButton() {
  const picker = useImagePicker()
  const caption = signal('')

  const change = async () => {
    const uri = await picker.pick()
    if (uri === null) return
    await uploadAvatar({
      multipart: { avatar: await fileFromUri(uri, { name: 'avatar.jpg' }), caption: caption() },
    })
  }
  // …
}
  • multipart becomes PyreonMultipartBody natively — parts in source order, a struct value as a JSON.stringify text part, an array repeating its field, null / undefined dropped, names and text normalized and escaped as the WHATWG FormData serialization does. An executed differential test holds the runtime writer to the bytes the web's FormData produces.

  • Files stream from disk. A file:// copy (iOS) or a content:// document (Android) is read in chunks when the request is sent and never held whole in memory; a data: URI is decoded the way fetch decodes it.

  • body: file sends the file's bytes as the whole body, typed by the file (or by a content-type you set). A string body is a text body.

  • form is serialized by the web's own encodeForm at compile time when every value is a literal, and by PyreonURL.query at request time when one is not — both produce the URLSearchParams bytes the web sends.

  • fileFromUri must be awaited (a Promise in a body is sent as text, so the unawaited call is named). On Android it reads through the app's ContentResolver; a URI that names nothing readable rejects, ending the handler.

  • Not lowered, each named in a warning: a multipart field holding a runtime array of objects (send one field per object or a single JSON.stringify value), a nested object in form, and passing two body encodings at once (the web throws).

Relationship to the rest of the framework

  • @pyreon/query — owns caching, dedup-by-key and invalidation. Use endpoint.query() so the key and the URL cannot drift, and so TanStack's AbortSignal is forwarded.

  • useFetch (@pyreon/hooks) — the tiny reactive container, and the multiplatform hook PMTC lowers to native. Reach for it when you want { data, error, isPending } and nothing else.

  • @pyreon/feature — builds on this client, so its generated CRUD hooks cancel correctly.

  • @pyreon/validation — owns the Standard Schema contract this package validates against.

HTTP