Out-of-the-box HTTP transports for clients generated by smithy-ts-codegen — including the ndjson / binary framing that streaming operations need.
The generated generated.ts is transport-agnostic: it declares a Transport
interface (and a StreamTransport when the model streams) and leaves the
implementation to you. This package is that implementation, so each project
stops re-writing the same fetch wrapper and the same ndjson read loop.
pnpm add @polyvariant/smithy-ts-runtimeZero runtime dependencies. ESM only.
import { fetchTransport } from '@polyvariant/smithy-ts-runtime'
import { DirectoryClient, FeedClient } from './generated.js'
const transport = fetchTransport({ baseUrl: '/api' })
const directory = new DirectoryClient(transport)
const feed = new FeedClient(transport, transport) // streaming ops take bothOne object implements both halves of the contract, so the same value is passed to every client in a model.
This package declares its own copies of Transport, StreamTransport and
friends, structurally identical to what the codegen emits. Nothing imports
anything: generated.ts stays self-contained, and TypeScript matches the two by
shape. Upgrading one without the other is safe as long as the contract holds —
and typecheck/src/runtimeUsage.ts in this repo fails to compile if it stops
holding.
| Option | Default | |
|---|---|---|
baseUrl |
'' |
Prefixed to every request path. '/api' or 'https://host/api'. |
credentials |
'include' |
So an HttpOnly session cookie rides along. 'omit' for token auth. |
headers |
— | Merged into every request; a function is re-evaluated per request. Per-request headers (from @httpHeader members) win. |
fetch |
global | Override for tests or a Node/undici instance. |
init |
{} |
Anything else fetch takes — mode, cache, signal, redirect. |
unauthenticated |
true |
See below. |
A non-2xx status is returned, not thrown: the generated client needs the
status and body to dispatch the operation's declared errors: [...].
401 is the exception. The auth middleware sits in front of every route, so it is
never modelled per-operation and the generated client cannot dispatch it — it
would surface as UnexpectedResponseError. By default the transport throws this
package's UnauthenticatedError instead.
If your call sites check the generated class, pass it in so instanceof keeps
working:
import { UnauthenticatedError } from './generated.js'
fetchTransport({
baseUrl: '/api',
unauthenticated: (operation) => new UnauthenticatedError(operation),
})unauthenticated: false leaves 401s alone.
fetchTransport implements StreamTransport too, applying the framing the
generator asks for — it never guesses from a content type:
StreamEncoding |
Wire | Elements |
|---|---|---|
'ndjson' |
application/x-ndjson |
one JSON value per line |
'binary' |
application/octet-stream |
Uint8Array chunks |
Both directions, and both are lazy: an ndjson response is deframed one line per pull, so a long-lived stream never buffers. The generated client validates each element against the operation's schema on top of this.
A streamed request body needs duplex: 'half', which only Chromium supports
(over HTTP/2). Elsewhere the outgoing stream is buffered into a single body —
correct, just not incremental. This is detected per platform, not configured.
Because a streamed response commits its HTTP status before the first element, a
mid-stream failure can't be a status — model it as a member of the streamed
union (the protocol's terminal completed / failed). A rejection from
requestStream therefore means the request failed before the stream started.
The framing primitives are exported for transports this package doesn't ship
(a WebSocket bridge, a Node http client, a test double): encodeNdjson,
decodeNdjson, encodeBinary, readableToAsyncIterable,
asyncIterableToReadable, collectBytes.
chain wraps a transport, outermost first:
import { chain, tap, withHeaders } from '@polyvariant/smithy-ts-runtime'
const transport = chain(
fetchTransport({ baseUrl: '/api' }),
withHeaders(() => ({ authorization: `Bearer ${token()}` })),
tap({ onError: (err, req) => report(req.operation, err) }),
)around(f)— the building block:f(req, next)runs code around the call. Tracing is one line:around((req, next) => withSpan(req.operation, next)).mapRequest(f)— rewrite a request before it goes out;fmay be async.withHeaders(h)— merge headers in; per-request headers still win.tap(handlers)— observeonRequest/onResponse/onErrorwithout changing anything.onResponsesees every response, including non-2xx.chainStream/aroundStream— the same forStreamTransport.
Each request carries operation ('DirectoryClient.getPerson') for naming a
span, and the options blob the codegen threads through untouched — which is
where per-call knobs like skipErrorPopup live.
When the handler closes over component state, it has to be registered later and removed on unmount, the way a response-interceptor registry works:
const interceptors = interceptorStack()
const transport = chain(fetchTransport({ baseUrl: '/api' }), interceptors.middleware)
// `use` returns its own remover, so it is an effect cleanup directly
useEffect(() => interceptors.use({ onError: (err) => showPopup(err) }), [])Handlers run in registration order, and a handler that ejects itself mid-request doesn't disturb that request.
Apache 2.0.