Menu
On this page

@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:

OptionTypeDefaultDescription
baseUrlstring—Base URL for every request. Required.
headersHeadersInit—Headers merged into every request.
fetchtypeof fetchglobal fetchInjected transport.
timingIClientTimingperformance.now() + abort-aware sleepClock and sleep seam, so tests need no real time.
retryClientRetryPolicyoffRetry with fixed or exponential backoff.
circuitBreakerCircuitBreakerPolicyoffRolling-window breaker.
rateLimitClientRateLimitPolicyoffSliding-window limiter.
idempotencyClientIdempotencyOptionsoffGenerated keys for POST/PATCH; see Keyed retries.
requestInterceptorsClientRequestInterceptor[][]Run in order before the request.
responseInterceptorsClientResponseInterceptor[][]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

OptionTypeDescription
baseUrlstring (required)Base URL for every request
headersRecord<string, string>Default headers cloned into each request
fetchFunctionInjectable fetch seam (default bound to the global realm)
timingIClientTimingInjectable timing seam (defaults to createDefaultClientTiming())
retryClientRetryPolicyRetry policy (transport failures + 408/425/429/5xx on safe methods; any method, and 409, when keyed)
circuitBreakerCircuitBreakerPolicyPer-origin circuit breaker policy
rateLimitClientRateLimitPolicyPer-origin sliding-window rate limiter
idempotencyClientIdempotencyOptionsGenerated idempotency keys; see Keyed retries
requestInterceptorsClientRequestInterceptor[]Run once before resilient execution
responseInterceptorsClientResponseInterceptor[]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;
}
  • path must be relative. A leading slash, a scheme-relative //host/x, and a fully absolute https://host/x are all rejected — otherwise a request could leave baseUrl’s origin and bypass the per-origin circuit breaker and rate limiter.
  • Query values are encoded; arrays repeat the key.
  • json is serialized with JSON.stringify; Content-Type: application/json is set automatically.
  • signal aborts fetches, queued waits, and retry attempts.

Response

interface ClientResponse<T> {
  status: number;
  headers: Headers;
  data?: T;
}
  • data is parsed when the response Content-Type names JSON: application/json or any structured +json suffix (application/problem+json, application/vnd.api+json). Media-type parameters and casing are ignored.
  • data is undefined for 204, for an empty body, and for a non-JSON content type.
  • Non-2xx responses throw HttpClientError rather 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:

  • timeout is the rolling window used to decide when the circuit trips — threshold counted failures inside it open the circuit.
  • resetTimeout is how long the circuit stays open, measured from the moment it tripped. It is not shortened by timeout elapsing, so timeout may safely be shorter than resetTimeout.

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 operationId is split on every run of non-alphanumeric characters and re-joined, so listUsers stays listUsers and get-users-{id} becomes getUsersId. A leading digit run is prefixed with n, a reserved word is prefixed with _, and an id that sanitizes to nothing becomes operation.

  • Component schemas and argument interfaces are PascalCase. Component User emits export type User, and operation listUsers emits export 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 with apiTypeName.

  • Two names that derive onto one identifier throw OpenApiCodegenError rather than emitting a file with a duplicate declaration, and the diagnostic names both originals. Component schemas, *Args interfaces, *Error unions, 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 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-plugin names 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 status and 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 3xx keeps its error arm, the auto-follow statuses (301, 302, 303, 307, and 308) included: Fetch returns such a response itself when it carries no Location, and the client then throws HttpClientError with that status. Any operation declaring an auto-follow redirect or a 3XX range returns unknown, because the follow target body is selected at runtime and is not described by the source operation.

    HttpClientError is generic in its body (HttpClientError<TBody = unknown>), so the bare name means what it always did. The union is discriminated on status because HttpClientError<A> | HttpClientError<B> is not — status is number on both arms. A default response and range codes such as 4XX are 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, because deno fmt rewraps 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 the client.request<…> type argument — so no single indentation is correct for a multi-line object literal and deno fmt reindents whatever is emitted. A schema that @setu-ts/openapi-plugin derived from validateBody and 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 what deno lint’s ban-types accepts. A narrowed pragma could not be emitted unconditionally either: an ignore matching nothing is itself reported as ban-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 request parameter — request?: OperationRequestOptions, after the path parameters and after opts — whose idempotencyKey and signal fields carry the same meaning as the same-named fields of ClientRequest; the generated code forwards only those two fields, and existing calls are unaffected. A path parameter whose identifier is client, opts, request or encodeURIComponent is emitted with a _ prefix, so it cannot shadow the factory’s client, the method’s locals or the global its path expression calls; path parameters are positional, so no caller changes. An operation whose method name is client or encodeURIComponent is emitted as _client or _encodeURIComponent — a renamed method, but before, client never compiled and encodeURIComponent broke every generated path that encodes a parameter. A document component named OperationRequestOptions is refused like ClientResponse, rather than shadowing the import the generated file claims from sdkImport.

  • opts is required when any of its fields is required. A required query parameter or a requestBody marked required makes the opts parameter 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-string header 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 same name and in.

  • Document text can never escape into code. Text emitted into a comment (the operationId on 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:

ConditionWhy it is rejected
Missing operationIdNo name to derive a method from
Two operations deriving onto one method nameDuplicate declaration; both originals are named
Two emitted TYPE names collidingDuplicate 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' parameterEmitted source would reference an undeclared argument
in: 'path' parameter absent from the path templateThe caller’s value would be silently dropped
Two placeholders in one template deriving onto one argument nameDuplicate parameter in the emitted signature
Malformed local $refNo 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

ErrorWhen thrown
HttpClientErrorNon-2xx HTTP response (carries status, headers, body)
ClientCircuitOpenErrorCircuit breaker is open for the request origin
OpenApiCodegenErrorInvalid OpenAPI document during code generation

Public Surface

ExportKindDescription
createClientfactoryCreates an IHttpClient instance
IHttpClientinterfaceClient contract (request<T>())
ClientOptionsinterfaceFactory options
ClientRequesttypeOutbound request description
ClientResponsetypeParsed response
ClientRequestContexttypeMutable context for request interceptors
ClientRequestInterceptortypeRequest interceptor signature
ClientResponseInterceptortypeResponse interceptor signature
IClientTiminginterfaceTiming seam (now(), sleep())
createDefaultClientTimingfactoryDefault timing over performance.now()
createObservedFetchfactoryDevtool outbound-attempt counters (M98n)
ObservedFetchinterface{ fetch, plugin } returned by it
ObservedFetchOptionsinterface{ alias, fetch?, timing? }
ClientRateLimitPolicytypeSliding-window rate limiter config
RetryPolicyre-exportFrom @setu-ts/common
CircuitBreakerPolicyre-exportFrom @setu-ts/common
BackoffStrategyre-exportFrom @setu-ts/common
createBearerAuthInterceptorfactoryBearer-token request interceptor
createApiKeyAuthInterceptorfactoryAPI-key request interceptor
HttpClientErrorerror classFailed non-2xx response
ClientCircuitOpenErrorerror classFail-fast circuit state
OpenApiCodegenErrorerror classInvalid-spec diagnostic
generateOpenApiClientfunctionPure OpenAPI → TypeScript generator
OpenApiCodegenOptionstypeGenerator options
SdkOpenApi*typesStructural 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.fetch at 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 — IClientTiming makes backoff, breaker windows, and rate-limit waits testable without real time.

Architecture · Public API · Roadmap

Exports

ExportKind
createApiKeyAuthInterceptorfunction
createBearerAuthInterceptorfunction
createClientfunction
createDefaultClientTimingfunction
createObservedFetchfunction
createRealtimeClientfunction
createSseClientfunction
createTraceContextInterceptorfunction
generateOpenApiClientfunction
ClientCircuitOpenErrorclass
HttpClientErrorclass
OpenApiCodegenErrorclass
CircuitBreakerPolicyinterface
ClientIdempotencyOptionsinterface
ClientOptionsinterface
ClientRateLimitPolicyinterface
ClientRequestinterface
ClientRequestContextinterface
ClientResponseinterface
IClientTiminginterface
IHttpClientinterface
IRealtimeClientinterface
ISseClientinterface
IWebSocketTransportinterface
ObservedFetchinterface
ObservedFetchOptionsinterface
OpenApiCodegenOptionsinterface
OperationRequestOptionsinterface
RawSseEventinterface
RealtimeClientOptionsinterface
RealtimeMessageinterface
RealtimeReconnectOptionsinterface
RetryPolicyinterface
SdkOpenApiDocumentinterface
SdkOpenApiOperationinterface
SdkOpenApiParameterinterface
SdkOpenApiPathIteminterface
SdkOpenApiRequestBodyinterface
SdkOpenApiResponseinterface
SdkOpenApiSchemainterface
SseClientOptionsinterface
SseEventinterface
SseReconnectOptionsinterface
BackoffStrategytype
ClientRequestInterceptortype
ClientResponseInterceptortype
ClientRetryPolicytype
RealtimeClientStatetype
SseClientStatetype
SseEventMaptype
WebSocketFactorytype

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;
};
OptionConsumerBehavior
baseUrlHttpClient URL resolverRequired base for every relative ClientRequest.path
headersHttpClient request builderCloned into each request; request-specific values win
fetchHttpClient transportCalled after policy gates; defaults to global fetch
timingretry, breaker, limiterOptional; defaults to createDefaultClientTiming()
retryretry strategylimit must be a positive safe integer; every configured or derived delay must be finite, non-negative, and at most 2_147_483_647 ms
maxRetryAfterMsretry strategyClientRetryPolicy member; same timer-safe bound. A larger Retry-After surfaces the response error immediately, without sleeping or retrying
circuitBreakerorigin breaker mapthreshold < 1 throws at construction
rateLimitorigin limiter mapNon-positive maxRequests/windowMs throws
requestInterceptorsrequest pipelineRun once in array order before resilient execution
responseInterceptorsresponse pipelineRun 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

ErrorWhen thrownFields
HttpClientErrorNon-2xx HTTP responsestatus, headers, body
ClientCircuitOpenErrorCircuit breaker is open for the origin(none)
OpenApiCodegenErrorInvalid OpenAPI document during codegenpath, 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;
}
OptionDefaultDescription
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 symbolDerivation
Operation methodlower-camelCase from operationId, split on non-alphanumeric runs, interior casing preserved (listUsers → listUsers)
Component typePascalCase from the component name (User → export type User)
Argument interfacePascalCase from operationId plus Args (listUsers → ListUsersArgs)
Client interfaceapiTypeName, PascalCase-sanitized (default Api); the factory’s written-out return type
Error unionPascalCase 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 aliasPascalCase from operationId plus Error<status>Body, emitted only when the rendered body spans lines
Request body aliasPascalCase from operationId plus Body, emitted only when the body schema is inline and spans lines
Response aliasPascalCase from operationId plus Response<status>, emitted only when a 2xx schema is inline and spans lines
Parameter aliasPascalCase from operationId plus the parameter name plus Param, emitted only when the parameter schema is inline and spans lines
Leading digit / reserveddigit run prefixed n; reserved word prefixed _; a name that sanitizes to nothing becomes operation
Local-identifier renamea 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 namea 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.


Agent card · TypeDoc API reference