@setu-ts/sdk
Portable, zero-npm-dependency client SDK for consuming Setu-TS APIs from browsers and servers.
Provides an injectable fetch HTTP client, bearer and API-key authentication interceptors,
per-origin retry with backoff, circuit breaker, and sliding-window rate limiting, plus a pure
generator that turns OpenAPI 3.1 documents into type-checked TypeScript client source.
This package does not register a plugin or resolve capability tokens. It is an external-consumer
library with no dependency on kernel, runtime, or any plugin — its only in-repo import is
type-level from @setu-ts/common.
Realtime clients
createSseClient consumes an SSE endpoint through fetch on every supported runtime. It sends
configured headers on every reconnect, ignores comment-frame heartbeats, parses JSON events, applies
server retry: delays, and sends Last-Event-ID after an identified event. It does not delegate to
EventSource, so bearer authentication works equally in browsers and server runtimes.
import { createSseClient } from '@setu-ts/sdk';
const events = createSseClient({
url: 'https://api.example.com/sse/scores',
headers: { Authorization: 'Bearer token' },
onEvent: ({ event, data }) => console.log(event, data),
});
// later: events.close()
createRealtimeClient wraps the global WebSocket with the server’s application-level keep-alive
contract. It filters the heartbeat payload ('ping' by default), replies to keep the server’s
inbound-idle timer fresh, reconnects with backoff, and re-applies the configured room query value.
import { createRealtimeClient } from '@setu-ts/sdk';
const board = createRealtimeClient({
url: 'wss://api.example.com/ws/board',
room: 'game-7',
onMessage: ({ data }) => console.log(data),
});
Installation
# npm / pnpm / yarn
npm install @setu-ts/sdk@^0.11.0
# Bun
bun add @setu-ts/sdk@^0.11.0
# Deno
deno add npm:@setu-ts/sdk@^0.11.0
Quick Start
import { createBearerAuthInterceptor, createClient } from '@setu-ts/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
requestInterceptors: [createBearerAuthInterceptor('my-token')],
retry: {
limit: 3,
delay: 500,
backoff: 'exponential',
},
});
const res = await client.request<User>({
method: 'GET',
path: 'users/123',
});
console.log(res.data); // User
Options
createClient(options) takes:
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | — | Base URL for every request. Required. |
headers | HeadersInit | — | Headers merged into every request. |
fetch | typeof fetch | global fetch | Injected transport. |
timing | IClientTiming | performance.now() + abort-aware sleep | Clock and sleep seam, so tests need no real time. |
retry | ClientRetryPolicy | off | Retry with fixed or exponential backoff. |
circuitBreaker | CircuitBreakerPolicy | off | Rolling-window breaker. |
rateLimit | ClientRateLimitPolicy | off | Sliding-window limiter. |
idempotency | ClientIdempotencyOptions | off | Generated keys for POST/PATCH; see Keyed retries. |
requestInterceptors | ClientRequestInterceptor[] | [] | Run in order before the request. |
responseInterceptors | ClientResponseInterceptor[] | [] | Run in order after the response. |
fetch and timing are the two seams that keep the client testable without a network or real time
— Date.now() appears nowhere in this package. See Resilience for the three policy
shapes and Authentication for the bundled interceptors.
The fetch default needs no browser-specific value: it resolves globalThis.fetch at call
time, with the global as its receiver, so the default transport works in a browser unchanged. (An
injected fetch is always used as-is, which is what tests rely on.)
HTTP Client
createClient(options) returns an IHttpClient whose single method is
request<TResponse, TBody>(request: ClientRequest<TBody>): Promise<ClientResponse<TResponse>>.
ClientOptions
| Option | Type | Description |
|---|---|---|
baseUrl | string (required) | Base URL for every request |
headers | Record<string, string> | Default headers cloned into each request |
fetch | Function | Injectable fetch seam (default bound to the global realm) |
timing | IClientTiming | Injectable timing seam (defaults to createDefaultClientTiming()) |
retry | ClientRetryPolicy | Retry policy (transport failures + 408/425/429/5xx on safe methods; any method, and 409, when keyed) |
circuitBreaker | CircuitBreakerPolicy | Per-origin circuit breaker policy |
rateLimit | ClientRateLimitPolicy | Per-origin sliding-window rate limiter |
idempotency | ClientIdempotencyOptions | Generated idempotency keys; see Keyed retries |
requestInterceptors | ClientRequestInterceptor[] | Run once before resilient execution |
responseInterceptors | ClientResponseInterceptor[] | Run after successful parse; skipped on failure |
Request
interface ClientRequest<TBody = never> {
method: string;
path: string;
query?: Record<string, string | string[]>;
headers?: Record<string, string>;
json?: TBody;
idempotencyKey?: string;
signal?: AbortSignal;
}
pathmust be relative. A leading slash, a scheme-relative//host/x, and a fully absolutehttps://host/xare all rejected — otherwise a request could leavebaseUrl’s origin and bypass the per-origin circuit breaker and rate limiter.- Query values are encoded; arrays repeat the key.
jsonis serialized withJSON.stringify;Content-Type: application/jsonis set automatically.signalaborts fetches, queued waits, and retry attempts.
Response
interface ClientResponse<T> {
status: number;
headers: Headers;
data?: T;
}
datais parsed when the responseContent-Typenames JSON:application/jsonor any structured+jsonsuffix (application/problem+json,application/vnd.api+json). Media-type parameters and casing are ignored.dataisundefinedfor204, for an empty body, and for a non-JSON content type.- Non-2xx responses throw
HttpClientErrorrather than resolving.
Authentication
Bearer Token
import { createBearerAuthInterceptor } from '@setu-ts/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
requestInterceptors: [
// Literal token
createBearerAuthInterceptor('my-token'),
// Or async provider
createBearerAuthInterceptor(() => tokenStore.get()),
],
});
API Key
import { createApiKeyAuthInterceptor } from '@setu-ts/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
requestInterceptors: [
// Default header: X-API-Key
createApiKeyAuthInterceptor('my-key'),
// Custom header
createApiKeyAuthInterceptor('my-key', 'Authorization'),
],
});
Both factories accept a literal string or an async value provider. The header is only set when the request has not already supplied that header.
Resilience
Retry
import { BackoffStrategy } from '@setu-ts/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
retry: {
limit: 3, // total attempts
delay: 500, // base delay in ms
backoff: 'exponential' as BackoffStrategy,
},
});
Retries transport rejections and statuses 408, 425, 429, 500-599. By default, only retries safe
methods (GET, HEAD, OPTIONS, PUT, DELETE). When a retryable response carries a Retry-After header
with delta-seconds, that delay replaces the computed backoff only when it is within the policy cap.
Set maxRetryAfterMs explicitly, or omit it to use the policy’s largest fixed/exponential backoff.
A larger hint surfaces the original HttpClientError immediately, with its headers intact.
At construction, limit must be a positive safe integer, delay must be finite and non-negative,
and every explicit or derived delay must be at most 2_147_483_647 ms, the portable JavaScript
timer maximum. Runtime strings, non-finite values, and timer-overflowing delays are rejected so they
cannot disable the cap or be clamped into an immediate retry.
Keyed retries
ClientRequest.idempotencyKey supplies a key; ClientOptions.idempotency generates one by default
for POST and PATCH. The default header is Idempotency-Key; configure idempotency.header to
match the server. When ClientOptions.idempotency applies to the request’s method and
ClientRequest.idempotencyKey is absent, a request that already carries the configured header uses
its value as the key. Without matching configuration the header is sent but does not make the
request keyed, and a request carrying both idempotencyKey and that header is refused rather than
either value replacing the other. The same key is reused across retry attempts, including a
retryable 409.
Keyed POST/PATCH retries are safe ONLY when the server de-duplicates on that key, for example
with idempotent() or within. Against a server that does not, a retried POST can execute twice.
A keyed request carries a non-safelisted header, so a cross-origin browser call triggers a CORS
preflight; the server must list that header in Access-Control-Allow-Headers. For the page to read
the replay response, Access-Control-Expose-Headers must list Idempotent-Replayed.
A generated operation takes the same key per call in its trailing request parameter
(request?: OperationRequestOptions): api.createOrder({ body }, { idempotencyKey }). The
idempotencyKey and signal fields carry the same meaning as the same-named fields of
ClientRequest, and the generated code forwards only those two fields.
Circuit Breaker
const client = createClient({
baseUrl: 'https://api.example.com',
circuitBreaker: {
threshold: 5,
timeout: 30_000,
resetTimeout: 10_000,
},
});
Per-origin rolling-window circuit breaker. An open circuit throws ClientCircuitOpenError before
consuming a rate-limit slot. The breaker counts one failure per exhausted transient sequence (not
one per retry attempt).
The two windows are independent:
timeoutis the rolling window used to decide when the circuit trips —thresholdcounted failures inside it open the circuit.resetTimeoutis how long the circuit stays open, measured from the moment it tripped. It is not shortened bytimeoutelapsing, sotimeoutmay safely be shorter thanresetTimeout.
After resetTimeout elapses, exactly one half-open probe is admitted; concurrent callers get
ClientCircuitOpenError. A successful probe closes the circuit and clears the window. A failed
probe reopens it and restarts resetTimeout, so a dead dependency is probed once per cooldown
rather than on every request.
What counts as a failure: a 5xx response, a transport rejection, or an exhausted retry sequence. A
4xx HttpClientError does not — a bad request means the caller was wrong, not that the origin
is unhealthy — and neither does a caller abort.
Rate Limiting
const client = createClient({
baseUrl: 'https://api.example.com',
rateLimit: {
maxRequests: 100,
windowMs: 60_000,
},
});
Per-origin sliding-window limiter. When the window is full, the client waits until the oldest retained timestamp expires.
Interceptors
Request Interceptors
import { ClientRequestContext, ClientRequestInterceptor } from '@setu-ts/sdk';
const loggingInterceptor: ClientRequestInterceptor = (ctx: ClientRequestContext) => {
// The context carries the resolved `url` and the mutable `headers`; the
// method is not part of it.
console.log(ctx.url.href);
ctx.headers.set('x-request-id', crypto.randomUUID());
};
const client = createClient({
baseUrl: 'https://api.example.com',
requestInterceptors: [loggingInterceptor],
});
Request interceptors receive a mutable ClientRequestContext (resolved URL and Headers) and
execute once in registration order before the outbound attempt sequence.
Use createTraceContextInterceptor(telemetry) to propagate the active span as a W3C traceparent.
It reads activeSpanContext() per request, preserves a caller-supplied header, and omits invalid or
absent contexts. The telemetry service is structural and imported type-only, so the SDK keeps its
zero-runtime-dependency boundary.
Response Interceptors
import { ClientRequestContext, ClientResponse, ClientResponseInterceptor } from '@setu-ts/sdk';
const timingInterceptor: ClientResponseInterceptor<unknown> = (
response: ClientResponse<unknown>,
request: ClientRequestContext,
) => {
console.log(`${request.url.href} → ${response.status}`);
return response;
};
Response interceptors receive a successful ClientResponse<T> and its immutable request
description. They are skipped entirely when the request throws.
OpenAPI Code Generation
generateOpenApiClient(document, options?) is a pure function that turns an OpenAPI 3.1 document
into TypeScript source.
import { generateOpenApiClient } from '@setu-ts/sdk';
import { readFileSync } from 'node:fs';
const document = JSON.parse(readFileSync('openapi.json', 'utf-8'));
const source = generateOpenApiClient(document, {
sdkImport: '@setu-ts/sdk',
factoryName: 'createApi',
apiTypeName: 'Api',
});
// Write `source` to a file, then import the generated factory:
// import { createApi } from './generated-api.js';
// const api = createApi(client);
The generated factory accepts an IHttpClient and returns typed operation methods. Schema mapping
covers the M21 vocabulary: primitives, arrays, objects with required, $ref, enum, const,
anyOf, allOf, additionalProperties, null, and integer.
The output is publishable, formatted and lint-clean. createApi has a written-out return type
(export interface Api), so JSR does not reject the file as a slow type; the source round-trips
through deno fmt unchanged and needs no lint pragma. Both properties are load-bearing rather than
cosmetic: the generated file’s own header says “Do not edit manually”, so a consumer who cannot
publish or format it has nowhere to go.
Generated names and shapes
-
Operation methods are lower-camelCase and preserve interior casing. An
operationIdis split on every run of non-alphanumeric characters and re-joined, solistUsersstayslistUsersandget-users-{id}becomesgetUsersId. A leading digit run is prefixed withn, a reserved word is prefixed with_, and an id that sanitizes to nothing becomesoperation. -
Component schemas and argument interfaces are PascalCase. Component
Useremitsexport type User, and operationlistUsersemitsexport interface ListUsersArgs. -
The factory returns a named interface.
export interface Api { … }lists every operation’s signature and is the factory’s return type; rename it withapiTypeName. -
Two names that derive onto one identifier throw
OpenApiCodegenErrorrather than emitting a file with a duplicate declaration, and the diagnostic names both originals. Component schemas,*Argsinterfaces,*Errorunions, the client interface and theapiTypeName/factoryNameoptions all draw from ONE registry, so a component namedListUsersArgsbeside an operationlistUsersis refused rather than silently emitting two declarations of one name. Each of those names derives from something the document or the caller WROTE, so a clash is a real problem you must see. -
A hoisted alias ALLOCATES its name instead of throwing. The four hoisted aliases — a request body, a parameter, a success response and an error body — name an anonymous inline schema the document never named, so the generator may pick any identifier and a clash is its own problem to solve. Each asks the registry for its preferred name first, so no alias that generates today is ever renamed; a success response then tries
…Response<status>Body, which reads correctly beside a component of the same name; and every arm finally takes a numeric suffix (…2,…3) allocated through the registry, because a suffix is not a namespace and a document may legally declare the suffixed name too. This matters because@setu-ts/openapi-pluginnames a reused response schema${operationId}Response${status}— exactly the derivation used here — so a schema that is both reused and enclosed in an inline body used to abort generation outright. -
Declared error responses are typed. An operation declaring an observable non-2xx response also emits a union discriminated on the literal
statusand a narrowing guard:try { await api.getUserById('1'); } catch (e) { if (isGetUserByIdError(e) && e.status === 404) { e.body.code; // typed by the document's 404 schema } }Every declared
3xxkeeps its error arm, the auto-follow statuses (301,302,303,307, and308) included: Fetch returns such a response itself when it carries noLocation, and the client then throwsHttpClientErrorwith that status. Any operation declaring an auto-follow redirect or a3XXrange returnsunknown, because the follow target body is selected at runtime and is not described by the source operation.HttpClientErroris generic in its body (HttpClientError<TBody = unknown>), so the bare name means what it always did. The union is discriminated onstatusbecauseHttpClientError<A> | HttpClientError<B>is not —statusisnumberon both arms. Adefaultresponse and range codes such as4XXare skipped: they name no single status. -
Formatting matches
deno fmt. Two-space indentation, a signature past 100 columns wrapped one parameter per line, and a path template too long for one line emitted as an equivalent[…].join('')— a template literal is not usable there, becausedeno fmtrewraps a long one at whichever${happens to fit, which no generator can predict. -
No multi-line type is written at a use site. An inline (non-
$ref) body, parameter or success response is hoisted into an exported alias. A rendered type lands at several indentation levels, and a success type lands at two at once — the client interface’s signature and theclient.request<…>type argument — so no single indentation is correct for a multi-line object literal anddeno fmtreindents whatever is emitted. A schema that@setu-ts/openapi-pluginderived fromvalidateBodyand used once arrives inline, so this is the ordinary case. -
No lint pragma. An empty-object schema emits
Record<PropertyKey, never>rather than{}, which is both what the schema means and whatdeno lint’sban-typesaccepts. A narrowed pragma could not be emitted unconditionally either: an ignore matching nothing is itself reported asban-unused-ignore. -
Path parameters are positional arguments; everything else lives in
opts. Each path placeholder is substituted and percent-encoded, including a placeholder that shares a segment with literal text (/files/{id}.json). -
Every operation ends with an optional trailing
requestparameter —request?: OperationRequestOptions, after the path parameters and afteropts— whoseidempotencyKeyandsignalfields carry the same meaning as the same-named fields ofClientRequest; the generated code forwards only those two fields, and existing calls are unaffected. A path parameter whose identifier isclient,opts,requestorencodeURIComponentis emitted with a_prefix, so it cannot shadow the factory’sclient, the method’s locals or the global its path expression calls; path parameters are positional, so no caller changes. An operation whose method name isclientorencodeURIComponentis emitted as_clientor_encodeURIComponent— a renamed method, but before,clientnever compiled andencodeURIComponentbroke every generated path that encodes a parameter. A document component namedOperationRequestOptionsis refused likeClientResponse, rather than shadowing the import the generated file claims fromsdkImport. -
optsis required when any of its fields is required. A required query parameter or arequestBodymarkedrequiredmakes theoptsparameter itself required, so a caller cannot omit it and silently skip a mandatory value. -
Wire names are preserved. The query key and header name sent on the wire are the original OpenAPI names (
user_id,X-API-Key); only the TypeScript field identifier is derived. Header values are stringified, so a non-stringheader schema still compiles. -
All eight operation slots are generated, including
trace. Parameters declared at the path-item level are merged into every operation on that path; an operation’s own parameter overrides a shared one with the samenameandin. -
Document text can never escape into code. Text emitted into a comment (the
operationIdon each generated method) has comment terminators escaped and line breaks collapsed, and every emitted string literal and path template is escaped for its own context.
Codegen diagnostics
Declared OpenAPI ids name generated methods just like derived ones: listUsers produces
listUsers. Distinct ids may sanitize to one method name: declared getUsers beside undeclared
GET /users (derived get-users) is valid OpenAPI, but code generation refuses it with
Duplicate operation name. Choose ids that yield distinct TypeScript names. Changing an id also
changes its generated argument, error and inline-schema type names; regenerate clients and update
their callers.
generateOpenApiClient throws OpenApiCodegenError — carrying path and method where they apply
— rather than emitting a client that misbehaves or does not compile:
| Condition | Why it is rejected |
|---|---|
Missing operationId | No name to derive a method from |
| Two operations deriving onto one method name | Duplicate declaration; both originals are named |
| Two emitted TYPE names colliding | Duplicate declaration; one registry covers every emitted name. A HOISTED alias is the exception and does NOT throw — it allocates (see the naming rules above) |
Parameter with in: 'cookie' | Unsupported location |
Path placeholder with no matching in: 'path' parameter | Emitted source would reference an undeclared argument |
in: 'path' parameter absent from the path template | The caller’s value would be silently dropped |
| Two placeholders in one template deriving onto one argument name | Duplicate parameter in the emitted signature |
Malformed local $ref | No component name to resolve |
Outbound HTTP observations (devtool)
createObservedFetch wraps a fetch so the local diagnostics connector can count its attempts: how
many started and are in flight, how many resolved or failed, the last response’s status class and
its time to headers. It never reads a URL, header, body, signal or error, and it never changes what
the wrapped fetch receives or returns. Build it only when the application factory receives its
devtool composition, so a production entry carries no wrapper at all, and register its plugin in
that one application:
import type { IPlugin } from '@setu-ts/common';
import type { KernelDiagnosticsOptions } from '@setu-ts/kernel';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { createClient, createObservedFetch } from '@setu-ts/sdk';
const PAYMENTS_URL = 'https://payments.internal.example';
export function createApp(
_env?: Readonly<Record<string, unknown>>,
devtool?: { plugins?: readonly IPlugin[]; diagnostics?: KernelDiagnosticsOptions },
) {
const observed = devtool ? createObservedFetch({ alias: 'payments-api' }) : undefined;
const payments = createClient({
baseUrl: PAYMENTS_URL,
...(observed ? { fetch: observed.fetch } : {}),
});
void payments; // routes use `payments`
return createApplication({
plugins: [RuntimePlugin(), ...(observed ? [observed.plugin] : []), ...(devtool?.plugins ?? [])],
...(devtool?.diagnostics !== undefined ? { diagnostics: devtool.diagnostics } : {}),
});
}
The alias is disclosed to a paired devtool as written — choose a non-secret label, never a hostname.
A wrapped fetch should not depend on its receiver (prefer (input, init) => fetch(input, init)); a
direct observed.fetch(url) call forwards undefined. One helper serves one application:
registering its plugin in a second one throws. The helper needs crypto.getRandomValues (Node ≥
19, Deno, Bun, workerd, browsers).
Errors
| Error | When thrown |
|---|---|
HttpClientError | Non-2xx HTTP response (carries status, headers, body) |
ClientCircuitOpenError | Circuit breaker is open for the request origin |
OpenApiCodegenError | Invalid OpenAPI document during code generation |
Public Surface
| Export | Kind | Description |
|---|---|---|
createClient | factory | Creates an IHttpClient instance |
IHttpClient | interface | Client contract (request<T>()) |
ClientOptions | interface | Factory options |
ClientRequest | type | Outbound request description |
ClientResponse | type | Parsed response |
ClientRequestContext | type | Mutable context for request interceptors |
ClientRequestInterceptor | type | Request interceptor signature |
ClientResponseInterceptor | type | Response interceptor signature |
IClientTiming | interface | Timing seam (now(), sleep()) |
createDefaultClientTiming | factory | Default timing over performance.now() |
createObservedFetch | factory | Devtool outbound-attempt counters (M98n) |
ObservedFetch | interface | { fetch, plugin } returned by it |
ObservedFetchOptions | interface | { alias, fetch?, timing? } |
ClientRateLimitPolicy | type | Sliding-window rate limiter config |
RetryPolicy | re-export | From @setu-ts/common |
CircuitBreakerPolicy | re-export | From @setu-ts/common |
BackoffStrategy | re-export | From @setu-ts/common |
createBearerAuthInterceptor | factory | Bearer-token request interceptor |
createApiKeyAuthInterceptor | factory | API-key request interceptor |
HttpClientError | error class | Failed non-2xx response |
ClientCircuitOpenError | error class | Fail-fast circuit state |
OpenApiCodegenError | error class | Invalid-spec diagnostic |
generateOpenApiClient | function | Pure OpenAPI → TypeScript generator |
OpenApiCodegenOptions | type | Generator options |
SdkOpenApi* | types | Structural OpenAPI 3.1 subset |
Portability
- Zero npm dependencies — uses only web-standard APIs (
URL,Headers,AbortSignal,performance.now()). - Browser and server — runs in browsers, Node.js, Deno, Bun, and Cloudflare Workers. The default
transport needs no browser-specific value: it resolves
globalThis.fetchat call time with the global as its receiver, so the default works in a browser unchanged. - Injectable
fetch— tests inject a fake; the default is bound to the global realm. - Injectable timing —
IClientTimingmakes backoff, breaker windows, and rate-limit waits testable without real time.
Architecture · Public API · Roadmap
Exports
| Export | Kind |
|---|---|
createApiKeyAuthInterceptor | function |
createBearerAuthInterceptor | function |
createClient | function |
createDefaultClientTiming | function |
createObservedFetch | function |
createRealtimeClient | function |
createSseClient | function |
createTraceContextInterceptor | function |
generateOpenApiClient | function |
ClientCircuitOpenError | class |
HttpClientError | class |
OpenApiCodegenError | class |
CircuitBreakerPolicy | interface |
ClientIdempotencyOptions | interface |
ClientOptions | interface |
ClientRateLimitPolicy | interface |
ClientRequest | interface |
ClientRequestContext | interface |
ClientResponse | interface |
IClientTiming | interface |
IHttpClient | interface |
IRealtimeClient | interface |
ISseClient | interface |
IWebSocketTransport | interface |
ObservedFetch | interface |
ObservedFetchOptions | interface |
OpenApiCodegenOptions | interface |
OperationRequestOptions | interface |
RawSseEvent | interface |
RealtimeClientOptions | interface |
RealtimeMessage | interface |
RealtimeReconnectOptions | interface |
RetryPolicy | interface |
SdkOpenApiDocument | interface |
SdkOpenApiOperation | interface |
SdkOpenApiParameter | interface |
SdkOpenApiPathItem | interface |
SdkOpenApiRequestBody | interface |
SdkOpenApiResponse | interface |
SdkOpenApiSchema | interface |
SseClientOptions | interface |
SseEvent | interface |
SseReconnectOptions | interface |
BackoffStrategy | type |
ClientRequestInterceptor | type |
ClientResponseInterceptor | type |
ClientRetryPolicy | type |
RealtimeClientState | type |
SseClientState | type |
SseEventMap | type |
WebSocketFactory | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
API reference
SDK — Client SDK (@setu-ts/sdk)
Portable, zero-npm-dependency client SDK for consuming Setu-TS APIs from browsers and servers. Does not register a plugin or resolve capability tokens — it is an external-consumer library.
Installation
npm install @setu-ts/sdk@^0.11.0
createClient()
Factory that returns an IHttpClient. Requires a base URL; accepts default headers, an injectable
fetch seam, a timing seam, resilience policies, rate-limit policy, and interceptor arrays.
ClientRequest.path must be relative — no leading slash, no absolute URL. A leading-slash path
would discard baseUrl’s own path prefix, and an absolute URL would leave baseUrl’s origin
entirely, which is what makes the per-origin breaker and rate limiter meaningful. A path that
violates the rule throws ClientRequest.path must be relative (no leading slash). at request time.
import { createClient } from '@setu-ts/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
headers: { 'X-Trace-Id': 'abc' },
});
const res = await client.request<User>({
method: 'GET',
path: 'users/123',
});
console.log(res.data); // User
IHttpClient
The public client interface. Its single generic method is request<TResponse, TBody>.
interface IHttpClient {
request<TResponse, TBody = unknown>(
request: ClientRequest<TBody>,
): Promise<ClientResponse<TResponse>>;
}
ClientOptions
interface ClientOptions {
baseUrl: string;
headers?: Record<string, string>;
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
timing?: IClientTiming;
retry?: ClientRetryPolicy;
circuitBreaker?: CircuitBreakerPolicy;
rateLimit?: ClientRateLimitPolicy;
idempotency?: ClientIdempotencyOptions;
requestInterceptors?: ClientRequestInterceptor[];
responseInterceptors?: ClientResponseInterceptor[];
}
ClientRetryPolicy adds the client-side server-hint cap to the shared retry policy:
type ClientRetryPolicy = RetryPolicy & {
readonly maxRetryAfterMs?: number;
};
| Option | Consumer | Behavior |
|---|---|---|
baseUrl | HttpClient URL resolver | Required base for every relative ClientRequest.path |
headers | HttpClient request builder | Cloned into each request; request-specific values win |
fetch | HttpClient transport | Called after policy gates; defaults to global fetch |
timing | retry, breaker, limiter | Optional; defaults to createDefaultClientTiming() |
retry | retry strategy | limit must be a positive safe integer; every configured or derived delay must be finite, non-negative, and at most 2_147_483_647 ms |
maxRetryAfterMs | retry strategy | ClientRetryPolicy member; same timer-safe bound. A larger Retry-After surfaces the response error immediately, without sleeping or retrying |
circuitBreaker | origin breaker map | threshold < 1 throws at construction |
rateLimit | origin limiter map | Non-positive maxRequests/windowMs throws |
requestInterceptors | request pipeline | Run once in array order before resilient execution |
responseInterceptors | response pipeline | Run in array order after successful parse; skipped on failure |
When maxRetryAfterMs is absent, the cap is the policy’s largest computed backoff: delay for
fixed backoff, or delay * 2 ** (limit - 1) for exponential backoff. A Retry-After at or below
the effective cap replaces the computed delay. Policy validation rejects runtime strings and
non-finite or timer-overflowing values so the derived cap cannot fail open or be clamped into an
immediate retry by the runtime.
ClientRequest
interface ClientRequest<TBody = unknown> {
method: string;
path: string;
query?: Record<string, string | string[]>;
headers?: Record<string, string>;
json?: TBody;
idempotencyKey?: string;
signal?: AbortSignal;
}
ClientRequest.idempotencyKey supplies a caller-owned key. ClientOptions.idempotency enables
generated keys through ClientIdempotencyOptions: methods?: readonly string[] defaults to
['POST', 'PATCH'], header?: string defaults to 'Idempotency-Key', and
generateKey?: () => string defaults to 32 lowercase hex characters from 16 random bytes. Methods
must be 1–16 HTTP tokens and the header must be an HTTP token. Default key generation requires
crypto.getRandomValues at construction. Both supplied and generated keys are validated before
network I/O: 1–255 characters from 0x21–0x7E, excluding ".
One key is set before request interceptors and reused for every retry attempt. A static default key
header is refused when idempotency is configured, as is a supplied key conflicting with a merged
request header. When idempotency is configured and a request in one of its methods already carries
the configured header, that header’s value is validated and used as the key, and no key is generated
— the path a generated operation takes when its document declares the header. Without idempotency,
merely setting the header does not opt into keyed retries. With a retry policy, a keyed request can
retry any method, including 409, as well as the existing 408/425/429/5xx and transport failures. A
keyed request outside the safe method set (for example, POST or PATCH) never retries a parsing or
interceptor failure after a 2xx arrives. Safe methods retain their existing retry behavior.
Without either idempotency option, retry behavior is unchanged.
Keyed POST/PATCH retries are safe ONLY when the server de-duplicates on that key, for example
with idempotent() or within. Against a server that does not, a retried POST can execute twice.
A keyed request carries a non-safelisted header, so a cross-origin browser call triggers a CORS
preflight; the server must list the configured header (default Idempotency-Key) in
Access-Control-Allow-Headers.
OperationRequestOptions
interface OperationRequestOptions {
readonly idempotencyKey?: string;
readonly signal?: AbortSignal;
}
The per-call options every generated operation takes as its optional trailing request parameter,
after the path parameters and after opts. idempotencyKey and signal carry the same meaning as
the same-named fields of ClientRequest, and the generated code forwards only those two fields.
headers is deliberately absent: header parameters the document declares are already typed fields
of the operation’s opts, and a free header map would conflict with the key header.
ClientResponse
interface ClientResponse<T> {
status: number;
headers: Headers;
data?: T;
}
Interceptors
type ClientRequestInterceptor = (ctx: ClientRequestContext) => void | Promise<void>;
type ClientResponseInterceptor<T> = (
response: ClientResponse<T>,
request: ClientRequestContext,
) => ClientResponse<T> | Promise<ClientResponse<T>>;
Request interceptors receive a mutable ClientRequestContext (resolved URL and Headers) and
execute once in registration order before the outbound attempt sequence. Response interceptors
receive a successful ClientResponse<T> and its immutable request description; they are skipped
entirely when the request throws.
createTraceContextInterceptor(source) reads source.activeSpanContext?() per request, validates
the W3C identifiers, and sets traceparent unless the caller already supplied it. Missing or
invalid active contexts set nothing.
IClientTiming and createDefaultClientTiming()
interface IClientTiming {
now(): number;
sleep(ms: number, signal?: AbortSignal): Promise<void>;
}
function createDefaultClientTiming(): IClientTiming;
now() uses performance.now() (monotonic); sleep() uses setTimeout with an abort listener.
Inject a deterministic implementation in tests.
createObservedFetch() (M98n)
interface ObservedFetchOptions {
readonly alias: string; // 1–64 UTF-8 bytes, no control characters
readonly fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
readonly timing?: Pick<IClientTiming, 'now'>;
}
interface ObservedFetch {
readonly fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
readonly plugin: IPlugin;
}
function createObservedFetch(options: ObservedFetchOptions): ObservedFetch;
Opt-in outbound attempt counters for the local diagnostics connector. fetch defaults to the SDK’s
call-time globalThis.fetch; timing defaults to createDefaultClientTiming() and is always
called as timing.now(), probed once at construction. The wrapper forwards the caller’s arguments
unread and its receiver unchanged — except the helper object itself, forwarded as undefined —
calls the wrapped fetch exactly once, and returns the identical value or rethrows the identical
value; a synchronous throw stays synchronous (a deliberate departure from the framework’s promise
rule, so wrapping never changes application behavior). It reads only the resolved value’s status.
Register plugin in exactly one application: it registers the source under
CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS, closes the helper on shutdown, refuses another application
(the same application registering it again reopens the source rather than refusing — though the
kernel currently refuses a retried start() of any application registering RuntimePlugin, so this
path is not a working recovery). Its name is outbound-http-diagnostics-<32 hex>, drawn from
crypto.getRandomValues (required; Node ≥ 19). Construct the helper only when a devtool composition
is present. Construction refusals are fixed messages that never echo a value.
ClientRateLimitPolicy
interface ClientRateLimitPolicy {
maxRequests: number;
windowMs: number;
}
Per-origin sliding-window limiter. When the window is full, the client waits until the oldest retained timestamp expires.
Re-exported policy types
RetryPolicy, CircuitBreakerPolicy, and BackoffStrategy are re-exported from @setu-ts/common
so consumers can name their types without adding common to their own manifest.
Authentication interceptors
function createBearerAuthInterceptor(
token: string | (() => string | Promise<string>),
): ClientRequestInterceptor;
function createApiKeyAuthInterceptor(
key: string | (() => string | Promise<string>),
headerName?: string,
): ClientRequestInterceptor;
createBearerAuthInterceptor sets Authorization: Bearer <token>. createApiKeyAuthInterceptor
sets the header named X-API-Key (default) or headerName. Each accepts a literal or an async
value provider and sets its header only when the request has not already supplied that header.
Errors
| Error | When thrown | Fields |
|---|---|---|
HttpClientError | Non-2xx HTTP response | status, headers, body |
ClientCircuitOpenError | Circuit breaker is open for the origin | (none) |
OpenApiCodegenError | Invalid OpenAPI document during codegen | path, method |
ClientCircuitOpenError is named distinctly from the resilience plugin’s CircuitOpenError to
avoid a barrel collision.
generateOpenApiClient()
Pure function that turns an M21-compatible OpenAPI 3.1 document into TypeScript source.
import { generateOpenApiClient } from '@setu-ts/sdk';
const source = generateOpenApiClient(document, {
sdkImport: '@setu-ts/sdk',
factoryName: 'createApi',
});
OpenApiCodegenOptions
interface OpenApiCodegenOptions {
sdkImport?: string;
factoryName?: string;
apiTypeName?: string;
}
| Option | Default | Description |
|---|---|---|
sdkImport | '@setu-ts/sdk' | Generated type-import specifier |
factoryName | 'createApi' | Exported generated factory name |
apiTypeName | 'Api' | Exported interface the factory returns (see below) |
Generated naming contract
| Emitted symbol | Derivation |
|---|---|
| Operation method | lower-camelCase from operationId, split on non-alphanumeric runs, interior casing preserved (listUsers → listUsers) |
| Component type | PascalCase from the component name (User → export type User) |
| Argument interface | PascalCase from operationId plus Args (listUsers → ListUsersArgs) |
| Client interface | apiTypeName, PascalCase-sanitized (default Api); the factory’s written-out return type |
| Error union | PascalCase from operationId plus Error, with guard is<Operation>Error — emitted for every declared non-2xx response, auto-follow redirects (301, 302, 303, 307, 308) included — Fetch returns one unfollowed when it carries no Location |
| Error body alias | PascalCase from operationId plus Error<status>Body, emitted only when the rendered body spans lines |
| Request body alias | PascalCase from operationId plus Body, emitted only when the body schema is inline and spans lines |
| Response alias | PascalCase from operationId plus Response<status>, emitted only when a 2xx schema is inline and spans lines |
| Parameter alias | PascalCase from operationId plus the parameter name plus Param, emitted only when the parameter schema is inline and spans lines |
| Leading digit / reserved | digit run prefixed n; reserved word prefixed _; a name that sanitizes to nothing becomes operation |
| Local-identifier rename | a path parameter whose identifier is client, opts, request or encodeURIComponent gets a _ prefix (positional, so callers are unaffected); an operation whose method name is client or encodeURIComponent gets a _ prefix — each name otherwise shadows a factory binding or the global the generated paths call |
| Duplicate derived name | a name the document or the caller WROTE throws OpenApiCodegenError naming both originals — component schemas, *Args, *Error unions, the guard, the client interface and the apiTypeName/factoryName options share ONE registry. A HOISTED alias instead allocates: its preferred name, then …Response<status>Body on the success arm, then a registry-allocated 2, 3, … suffix |
No multi-line type is written at a use site. An inline (non-$ref) request body, parameter or
success response is hoisted into an exported alias, so every reference to it is a single-line name.
This is not cosmetic: a rendered type lands at several indentation levels, and a success type lands
at two of them at once — the client interface’s signature and the client.request<…> type argument
— so no single indentation is correct for a multi-line object literal, and deno fmt reindents
whatever is emitted. Hoisting also makes the shape nameable by a consumer. A schema that
@setu-ts/openapi-plugin derived from validation middleware and used once is inline, so this is the
ordinary case rather than an exotic one.
The factory has a written-out return type. createApi(client: IHttpClient): Api, with
export interface Api { … } listing every operation’s signature. An inferred return type is a JSR
slow type: it blocks automatic .d.ts generation, so a consumer could not publish a package
containing the generated file — while the file’s own header tells them not to edit it. Naming the
interface is also the only way a consumer can name the client’s type.
Declared error responses are typed. For each operation declaring an observable non-2xx response
the generator emits a union discriminated on the literal status, plus a narrowing guard:
export type GetUserByIdError =
| (HttpClientError<NotFound> & { readonly status: 404 })
| (HttpClientError<GetUserByIdError409Body> & { readonly status: 409 });
export function isGetUserByIdError(e: unknown): e is GetUserByIdError { … }
HttpClientError is generic in its body (HttpClientError<TBody = unknown>), so the bare name
keeps meaning exactly what it did. The union must be discriminated on status to be usable —
HttpClientError<A> | HttpClientError<B> is not, because status is number on both arms. A
default response and range codes such as 4XX are skipped: they name no single status.
Every declared 3xx keeps a typed error arm, including the statuses the web transport follows
(301, 302, 303, 307, and 308): Fetch returns such a response unfollowed when it carries no
Location, and the client throws HttpClientError with that status. An operation declaring an
auto-follow redirect or the OpenAPI 3XX range returns unknown, even when it also declares a
2xx schema: the runtime Location selects a follow target body the source operation does not
describe.
Generated output is deno fmt- and deno lint-clean. Two-space indentation, nested inline
object types indented, no lint pragma ({} is emitted as Record<PropertyKey, never>, which is
both what the schema means and what ban-types accepts), signatures wrapped one parameter per line
past 100 columns, and a path template too long for one line emitted as an equivalent […].join('').
The two committed fixtures under packages/sdk/test/fixtures/ are real generator output and are
covered by the repository’s own fmt and lint gates — they carry no exclusion.
Path parameters are emitted as positional arguments (each substituted and percent-encoded, including
a placeholder sharing a segment with literal text such as /files/{id}.json); query parameters,
headers, and the JSON body live on opts. opts is required when any of its fields is
required. Query keys and header names keep their original OpenAPI spelling; only the TypeScript
field identifier is derived, and header values are stringified so a non-string header schema
compiles.
Every operation ends with an optional trailing request?: OperationRequestOptions, after the path
parameters and after opts; it is optional even when opts is required. The generated code
forwards only the idempotencyKey and signal fields, each carrying the same meaning as the
same-named field of ClientRequest.
All eight operation slots are generated, including trace. Path-item-level parameters are merged
into every operation on that path, with an operation’s own entry overriding a shared one of the same
name and in. Document text emitted into a comment has comment terminators escaped and line
breaks collapsed, and every emitted string literal and path template is escaped for its own context,
so a hostile document cannot inject code into the generated file.
generateOpenApiClient throws OpenApiCodegenError (carrying path and method where applicable)
instead of emitting a client that misbehaves or does not compile, for: a missing operationId; two
operations deriving onto one name; two emitted TYPE names colliding — component schemas, *Args
interfaces, *Error unions, the narrowing guards, the client interface and the
apiTypeName/factoryName options all draw from ONE registry, so a component named ListUsersArgs
beside an operation listUsers is refused rather than emitting two declarations of one name; a
cookie parameter; a path placeholder with no matching in: 'path' parameter; an in: 'path'
parameter absent from the template; two placeholders deriving onto one argument name; a document
component named OperationRequestOptions, the type the generated operations’ trailing request
parameter claims from sdkImport, refused like ClientResponse; and a malformed local $ref.
A hoisted alias is the one emitted name that does NOT throw on a collision — it allocates. The
four hoisted aliases (a request body, a parameter, a success response, an error body) name an
anonymous inline schema the document never named, so the generator may pick any identifier for it
and a clash is its own to resolve, whereas every throwing name above derives from something the
document or the caller wrote. Each alias asks the registry for its preferred name first, so no alias
that generates today is renamed; a success response then tries …Response<status>Body, which reads
correctly beside a component of the same name where the numeric form (…Response2002) would read as
a status code; and every arm finally takes a 2, 3, … suffix allocated through the registry,
since a suffix is not a namespace and a document may legally declare the suffixed name too. One
internal function owns both the decision to hoist and the allocation, so every hoisted alias follows
this rule and a future one gets it by construction. This matters because @setu-ts/openapi-plugin
names a reused response schema ${operationId}Response${statusCode}, the identical derivation: a
schema that is both reused (so it becomes that component) and enclosed in an inline multi-line body
(so the body is hoisted) used to abort generation with Duplicate generated name.
SdkOpenApi* types
SdkOpenApiDocument, SdkOpenApiPathItem, SdkOpenApiOperation, SdkOpenApiParameter,
SdkOpenApiRequestBody, SdkOpenApiResponse, and SdkOpenApiSchema are the structural OpenAPI 3.1
subset accepted by the generator. They are intentionally different from the openapi-plugin types
(which have different shapes) and take the SdkOpenApi* prefix to avoid a barrel collision.