@cogs/fetch-client
v0.4.0
Published
xior-based HTTP transport for Kubb v5 generated API clients — the single seam where base URL, auth, headers, timeouts and error shape live
Readme
@cogs/fetch-client
The HTTP transport seam that Kubb-generated API clients compile against. One place to configure base URL, auth, per-request headers, timeouts, credentials, and error shape — swap or instrument the transport without regenerating a single client.
Built on xior (the single runtime
dependency), which runs on native fetch. Node 20+ / modern browsers.
Why a seam
Generated clients should never import fetch or a specific HTTP library
directly. If they do, every regen re-bakes transport policy into hundreds of
files, and changing auth means changing generated code.
With Kubb v5, pluginFetch bundles its own runtime into src/.kubb/client.ts.
@cogs/kubb-transforms appends a seam block to it, run via
output.postGenerate, which points the runtime's transport at this package's
./kubb-transport subpath:
import { kubbTransport, rejectDotSegment, normalizeKubbError } from '@cogs/fetch-client/kubb-transport'kubbTransport sends through the configureClient() instance, so base URL,
getToken, getHeaders and the timeout apply to every generated call.
rejectDotSegment refuses a ./.. path param and normalizeKubbError keeps
error messages free of request paths. Failures from generated code are Kubb
ResponseErrors (.status, .data, and .error mirrored from .data).
The Kubb v4 client contract (Client, RequestConfig, ResponseErrorConfig, the
./client default export, FetchClient#request and its interceptors) is gone;
Kubb v5 output does not import any of it.
Configure once at bootstrap
import { configureClient } from '@cogs/fetch-client'
configureClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
getToken: () => supabase.auth.getSession().then((s) => s.data.session?.access_token),
getHeaders: () => ({ 'X-Selected-Org-Id': selectedOrgId() }),
})Every generated call now flows through it. Call configureClient again to
rebuild after a token refresh or tenant switch.
Hand-written callers (SSE, scripts) can use an isolated instance instead of the
module singleton; the Kubb v5 kubbTransport always sends through the
configureClient() singleton:
import { createFetchClient } from '@cogs/fetch-client'
const api = createFetchClient({ baseUrl })
const res = await api.stream('/events')Server mode (MCP servers, workers)
A process that serves many users must not hold one ambient token. Server mode
reads the bearer, extra headers and base URL from an AsyncLocalStorage store,
so interleaved requests never see each other's identity:
import { configureClient } from '@cogs/fetch-client'
import { requestAuthContext } from '@cogs/fetch-client/request-context'
configureClient({ mode: 'server', baseUrl: 'https://api.example.com', context: requestAuthContext })
// in the request dispatcher
requestAuthContext.run(
{ token, headers: { 'X-Selected-Org-Id': orgId }, baseUrl },
() => handler(args),
)getToken/getHeadersare rejected by type and at runtime.- A request made outside a store throws before any network call; so does an empty token.
- The store owns
Authorizationand every header it sets; per-call variants in any casing are dropped. - Any origin other than the store's (or configured) base is refused, so the bearer cannot leak. This covers
stream()too. - A rejected request's
XiorError.config.headersstill holds the bearer: never serialize those errors into tool results or logs. request-contextis a separate Node-only subpath, so browser bundles never loadnode:async_hooks.
configureClient({ owner }) takes a symbol; isConfiguredBy(owner) / isConfigured()
let a provider check that the live instance is still its own after another
provider (for example an unauthenticated instance on public pages) took over.
Error contract
Generated calls go through kubbTransport and reject with Kubb's ResponseError
on a non-2xx; network and timeout errors propagate unchanged as raw xior errors.
- Kubb
ResponseError(non-2xx):err.status,err.data(parsed body), anderr.error(mirrored fromdata); the message isRequest failed with status code N - Network/timeout: a raw xior error.
isXiorError(err)(re-exported) narrows it,err.responseisundefined, andisAbortError(err)detects timeout/abort
@cogs/react-query's createQueryClient reads err.status ?? err.response?.status
to detect a 401 and trigger logout.
Body handling
- xior serialises plain objects to JSON and sets the content type;
FormDataand friends are passed through so the runtime sets the boundary - 204/205 resolve with
data: undefined - Query
paramsare serialised OpenAPI-style:null/undefineddropped, arrays repeat the key,Date→ ISO, nested objects → JSON
Streaming (SSE)
xior always resolves a parsed body, so a caller that needs the live Response
(to read response.body) uses streamRequest(path, init) (or
instance.stream(path, init)). It reuses the configured base URL, getToken
and getHeaders, and sends Accept: text/event-stream. The timeout bounds
only the wait for response headers, never the body, so a long-lived stream is
not cut off.
Optional: multi-service config registry + URL routing trie
Everything above assumes one API. If a process talks to several generated
(Kubb) APIs — each with its own base URL, auth, or headers — this package also
ships an optional, additive registry + routing layer. Single-API consumers can
ignore this section entirely and keep using configureClient.
The pieces:
- Config registry (
setConfig/getConfig/setConfigs/updateConfig/addConfig/getAllConfigs) — a genericRecord<string, Partial<ClientConfig>>keyed by whatever name you choose (a "confkey"). Ships empty; you decide the keys. - Operations registry (
addOperation/addOperations/getOperation/getAllOperations/updateOperation/deleteOperation) — a typed map of{ path, method, confkey, operationId? }, one entry per generated endpoint, pointing at which confkey it belongs to.addOperation/addOperationstake aConflictPolicy('throw'|'ignore'|'overwrite', default'throw') for what happens when a key is registered twice with a different definition. OperationTriePath— a URL + HTTP-method routing trie.initializeTrie(operations)builds a shared trie from an operations snapshot;findMatchingOperation(url, method)resolves the operation key for an outgoing request.
import {
setConfig,
addOperations,
getAllOperations,
initializeTrie,
findMatchingOperation,
getOperation,
getConfig,
} from '@cogs/fetch-client'
setConfig('envmgr', { baseUrl: 'https://envmgr.internal' })
setConfig('yellowpages', { baseUrl: 'https://yellowpages.internal' })
addOperations({
listEnvironments: { path: '/api/environments', method: 'get', confkey: 'envmgr' },
getService: { path: '/api/services/:id', method: 'get', confkey: 'yellowpages' },
})
initializeTrie(getAllOperations())
const opKey = findMatchingOperation('/api/services/42', 'GET') // 'getService'
const confkey = opKey ? getOperation(opKey)?.confkey : undefined // 'yellowpages'
const config = confkey ? getConfig(confkey) : undefined // { baseUrl: 'https://yellowpages.internal' }Pair config with createFetchClient (or a small wrapper of your own) to
build/select the right FetchClient instance per service. This layer is
pure bookkeeping — it does not itself perform requests.
