Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@polyvariant/smithy-ts-runtime

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-runtime

Zero runtime dependencies. ESM only.

Usage

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 both

One object implements both halves of the contract, so the same value is passed to every client in a model.

It is not imported by the generated code

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.

fetchTransport

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

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.

Streaming

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.

Middleware

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; f may be async.
  • withHeaders(h) — merge headers in; per-request headers still win.
  • tap(handlers) — observe onRequest / onResponse / onError without changing anything. onResponse sees every response, including non-2xx.
  • chainStream / aroundStream — the same for StreamTransport.

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.

Interceptors added after the fact

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.

License

Apache 2.0.