Defjs

createClient, option helpers, and the Client type.

Client

createClient(...) returns a DerivableClient. Compose client options left to right, then call client(options?) to get a Client value and pass it to an operation: getUser(client(options), input).

createClient()

function createClient(...options: ClientOption[]): DerivableClient;

Options apply in order. Prefer creating the client inside the request boundary when interceptors close over auth or tenants. The returned factory is a function. Calling it returns a Client value and performs no I/O. The factory itself is also a Client and carries no request options.

  • options — Option helpers such as withEndpoint.
  • Returns a callable factory. The base configuration is shared by every client value and is not frozen at runtime. It is fixed by convention: nothing in the library changes it, and applications should not either.
import { createClient, withEndpoint } from "@defjs/core";

const client = createClient(withEndpoint("https://api.example.com"));

Option helpers

All helpers return ClientOption. A later call replaces an earlier one for the same setting, except withInterceptors, which appends.

withEndpoint()

function withEndpoint(endpoint: string): ClientOption;

Sets the base URL prepended to relative paths.

withInterceptors()

function withInterceptors(...interceptors: Interceptor[]): ClientOption;

Appends interceptors in registration order. Mixed kinds are filtered per transport when the operation runs.

withQuerySerializer()

function withQuerySerializer(serializer: QuerySerializer): ClientOption;

Replaces query encoding for HTTP, SSE, and WebSocket. serializer(query) receives the encoded wire object (aliases applied, Date as ISO, bigint as string) and must return the query string without a leading ?. It is not called when the request has no query. A builder that returns URLSearchParams skips it.

withHeaders()

function withHeaders(headers: HeadersInit): ClientOption;

Sets static default headers for HTTP and SSE requests. WebSocket requests do not use them. headers is copied when the option applies, so later changes to the object do not reach the client. A second withHeaders replaces the first; it does not merge.

Precedence for one request:

  1. The defaults are the starting header set.
  2. headers returned by the request builder replace a default of the same name. The first value for that name replaces the default; later values in the same array append only to that replacement. A scalar or a Headers instance also replaces. Other default names stay.
  3. Interceptors receive the merged headers and may change them.
  4. With the default HTTP transport, the XSRF header is added last, only when still absent (see withXSRF).

Use an interceptor for values that change per request: a refreshed Bearer token, or an HMAC over the serialized body.

There is no client-level default deadline. Pass signal: AbortSignal.timeout(ms) on a derived client when one call needs one. A manual abort is NET_ABORTED. AbortSignal.timeout classifies as NET_TIMEOUT.

import {
  createClient,
  defineRequest,
  struct,
  withEndpoint,
  withHeaders,
} from "@defjs/core";

const client = createClient(
  withEndpoint("https://api.example.com"),
  withHeaders({ "X-Client": "web", Accept: "application/json" }),
);

const search = defineRequest({
  method: "GET",
  path: "/search",
  input: struct.object({ q: struct.string() }),
  // This request's Accept replaces the client default; X-Client is still sent.
  builder: (input) => ({ query: input, headers: { Accept: "text/plain" } }),
  output: struct.string(),
});

async function main() {
  const [error, text] = await search(
    client({ signal: AbortSignal.timeout(30_000) }),
    { q: "defjs" },
  );
  if (error) {
    console.error(error.code);
    return;
  }
  console.log(text);
}

void main();

withHTTPHandle()

withSSEHandle()

withWebSocketHandle()

function withHTTPHandle(fetchImpl: FetchHandle): ClientOption;

function withSSEHandle(fetchImpl: FetchHandle): ClientOption;

function withWebSocketHandle(
  WebSocketImpl: WebSocketHandleConstructor,
): ClientOption;

Replace Fetch (HTTP/SSE) or the WebSocket constructor.

withCredentials()

function withCredentials(value: boolean): ClientOption;

When true, Fetch uses credentials: 'include' for HTTP and SSE. The default is off. WebSocket connections are not affected.

withXSRF()

function withXSRF(
  options?: HttpClientOptions["xsrf"],
): ClientOption;

Adds an XSRF token header to HTTP requests. options is the xsrf setting, the same shape as xsrf on ClientOptions: cookieName (default XSRF-TOKEN), headerName (default X-XSRF-TOKEN), and tokenProvider({ request }). Calling withXSRF() with no argument enables those defaults. SSE and WebSocket requests are not affected.

The default HTTP transport adds the header when all of these hold: the method is not GET, HEAD, OPTIONS or TRACE; the request does not already have that header; the request is same-origin; and a non-empty token is found. Whenever location exists, including in a Worker, the resolved request origin must match location.origin; without it, a request with a baseEndpoint counts as same-origin. Cross-origin requests do not call tokenProvider. The token comes from that provider when given, otherwise from document.cookie, which is read only when document exists.

SSE knobs

withSSEReconnect()

withSSEOnInvalidEvent()

function withSSEReconnect(
  options: ClientSSEOptions["reconnect"],
): ClientOption;

function withSSEOnInvalidEvent(
  handler: ClientSSEOptions["onInvalidEvent"],
): ClientOption;

WebSocket knobs

withWebSocketBeforeConnect()

withWebSocketProtocols()

withWebSocketHeartbeat()

withWebSocketReconnect()

function withWebSocketBeforeConnect(
  beforeConnect: ClientWebSocketOptions["beforeConnect"],
): ClientOption;

function withWebSocketProtocols(
  protocols: readonly string[],
): ClientOption;

function withWebSocketHeartbeat(
  options: ClientWebSocketOptions["heartbeat"],
): ClientOption;

function withWebSocketReconnect(
  options: ClientWebSocketOptions["reconnect"],
): ClientOption;

Types

DerivableClient

interface DerivableClient extends Client {
  (options?: RequestOptions): Client;
}

The root client returned by createClient, in two shapes. The factory is a function and is also a Client with no request options, so getUser(client, input) works directly. Calling it with RequestOptions returns a plain Client value for one call. That value is not callable, so client()() fails to compile and throws a TypeError at runtime. Derivation happens once, from the factory; there is no chain. Write the type as DerivableClient rather than ReturnType<typeof createClient>.

Client

interface Client {
  readonly [CLIENT]: ClientState;
}

A Client is a value, not a function. Calling the DerivableClient with RequestOptions returns one. The seven known fields are copied from the argument into a fresh object. Unknown fields are ignored and fields whose value is undefined are omitted. There is no parent overlay: client(), client(undefined), and client({}) return a client with no request options. Nested heartbeat, protocols, and reconnect values are kept by reference, not cloned. WebSocket reads them when the operation runs (it copies heartbeat fields and the protocols list at that time), so mutating those objects after deriving can still affect a later call. Applications should treat them as immutable after passing them. Deriving performs no I/O. A client value is not callable, and calling one fails to compile and throws a TypeError at runtime. Create a client value per call. The same value can be passed to many operations, but reusing a value that carries signal shares that signal. signal is not combined; combine signals with AbortSignal.any in application code.

Do not pass the factory to a React state setter (setState(factory) would call it as an updater) or use it as a Vue watch or computed source (those call a function).

RequestOptions

type RequestOptions = {
  signal?: AbortSignal;
  onUploadProgress?: HttpProgressFn;
  onDownloadProgress?: HttpProgressFn;
  beforeConnect?: ClientWebSocketOptions["beforeConnect"];
  protocols?: readonly string[];
  heartbeat?: ClientWebSocketOptions["heartbeat"];
  reconnect?: ClientWebSocketOptions["reconnect"];
};

Seven flat fields. Each protocol reads the fields it understands and ignores the rest. Passing heartbeat to HTTP is ignored, not a fault. SSE reads only signal from request options. SSE reconnect comes from client configuration (withSSEReconnect), so client({ reconnect }) affects WebSocket only. There is no compile-time protocol filter and no timeout or abort field.

FieldProtocolReplacement
signalHTTP, SSE, and WebSocketAborts the call. For SSE and WebSocket it covers the whole connection, including after open.
onUploadProgressHTTPIgnored by other protocols.
onDownloadProgressHTTPIgnored by other protocols.
beforeConnectWebSocketReplaces the client setting for that call.
protocolsWebSocketReplaces the client list.
heartbeatWebSocketReplaced whole. timeoutMs is the acknowledgement wait, not a request deadline.
reconnectWebSocketReplaced whole.

HttpExecuteOptions is the HTTP slice (signal plus progress). EventStreamExecuteOptions is { signal? }. WebSocketExecuteOptions is the WebSocket slice plus signal.

ClientOption

type ClientOption = (config: ClientConfig) => void;

Mutates the ClientConfig that createClient builds. Write one when no with* helper fits.

QuerySerializer

type QuerySerializer = (query: { [key: string]: unknown }) => string;

FetchHandle

type FetchHandle = (
  input: RequestInfo | URL,
  init?: RequestInit,
) => Promise<Response>;

The call signature Defjs needs from a Fetch implementation. It is not typeof fetch, so a mock does not need host extras such as preconnect. The default HTTP/SSE handles resolve globalThis.fetch on each call. HTTP uses (url, init) only for untouched platform Fetch; replaced globals and custom handles receive a Request. SSE always passes a Request. Importing Core without Fetch is valid; a default execution reports ENV_UNSUPPORTED if Fetch is still missing, while custom handles remain usable.

ClientSSEOptions

ClientWebSocketOptions

interface ClientSSEOptions {
  handle?: FetchHandle;
  onInvalidEvent?: (context: {
    reason: "missing-struct" | "validation-failed";
    message: { id: string; event: string; data: string };
    cause?: unknown;
    signal: AbortSignal;
  }) => unknown;
  reconnect?: {
    attempts?: number;
    delayMs?: number;
    factor?: number;
    jitter?: number;
    maxDelayMs?: number;
    shouldReconnect?: (context: {
      attempt: number;
      cause?: unknown;
      lastEventId: string;
      method: string;
      open?: {
        response: { status: number; statusText: string; url: string };
        url: string;
      };
    }) => boolean | Promise<boolean>;
  };
}

interface ClientWebSocketOptions {
  handle?: WebSocketHandleConstructor;
  beforeConnect?: (context: {
    attempt: number;
    signal: AbortSignal;
  }) => void | Promise<void>;
  heartbeat?: {
    intervalMs: number;
    isAck?: (message: unknown) => boolean;
    message?: <T = unknown>() => T | unknown;
    timeoutMs?: number;
  };
  protocols?: readonly string[];
  reconnect?: {
    attempts?: number;
    delayMs?: number;
    factor?: number;
    jitter?: number;
    maxDelayMs?: number;
    shouldReconnect?: (context: {
      attempt: number;
      cause?: unknown;
      code?: number;
      reason?: string;
      wasClean?: boolean;
    }) => boolean;
  };
}

Reconnect is opt-in for both transports: without withSSEReconnect / withWebSocketReconnect there is no retry. Once reconnect is set, these defaults apply:

FieldSSEWebSocket
attempts3 per outage3 per outage
delayMs10001000
factor12
jitter00
maxDelayMsno cap30000

For both transports, a successful handshake resets the per-outage retry budget, callback attempt, and backoff exponent. attempts: 0 disables retry; omitting attempts does not give unlimited retries within one outage. maxDelayMs caps the final delay after jitter and rounding; the platform timer limit of 2_147_483_647 ms also applies, including when SSE has no configured cap. A zero base stays zero; valid-number overflow saturates at the caps.

Reconnect options are validated before transport: attempts must be a non-negative safe integer; delayMs must be finite and non-negative; maxDelayMs must be a non-negative finite number or positive Infinity, which removes the configured cap but not the platform limit of 2_147_483_647 ms; factor must be finite and positive; jitter must be finite and within 0–1. Invalid values return REQ_OPTIONS_INVALID. WebSocket attempts: 0 disables subsequent retries, but all explicitly supplied reconnect fields are still validated before the initial connection, just as in SSE. Jitter scales the capped base in either direction before the final cap is applied.

For SSE, delayMs is only the starting base delay: a digits-only server retry: field replaces it for later attempts, including after recovery. shouldReconnect returning false stops retrying. The SSE callback also receives lastEventId, the request method, and the latest open info, and may be async. Without it, SSE reconnects only GET, HEAD, OPTIONS, PUT, and DELETE requests; with it, its result decides for every method. The latest event ID is sent as UTF-8 bytes in Last-Event-ID; an empty ID clears it and an ID containing NUL is ignored. The WebSocket callback receives the close code, reason and wasClean instead.

SSE onInvalidEvent handles an event with no matching struct, including no default (missing-struct), or whose data fails its struct (validation-failed). The event is always dropped; the handler's return value is ignored, and an error it throws does not close the stream.

For WebSocket, beforeConnect, heartbeat, protocols and reconnect can also be passed on a derived client; each replaces the client value for that call as a whole. heartbeat.message builds each ping; nothing is sent when it is omitted or returns undefined. heartbeat.timeoutMs waits for an acknowledgement. It is not a request deadline.

ClientOptions

interface ClientOptions extends HttpClientOptions {
  sse?: ClientSSEOptions;
  webSocket?: ClientWebSocketOptions;
}

The shown fields are sse and webSocket. The other seven — endpoint, headers, http, interceptors, querySerializer, xsrf, and withCredentials — are inherited from the non-exported HTTP base, and endpoint is required. There is no client-level timeout. createClient does not take this object; it takes option helpers.

ClientConfig

The resolved snapshot that option helpers mutate and an operation reads: ClientOptions after defaults, with http.handle, interceptors, sse (a ClientSSEConfig) and webSocket always present.

ClientSSEConfig

interface ClientSSEConfig extends ClientSSEOptions {
  handle: FetchHandle;
}

The resolved SSE config on ClientConfig.sse: ClientSSEOptions with a required handle.

WebSocketHandle

The WebSocket instance surface the transport uses: WebSocket without binaryType, the ready-state constants, dispatchEvent and the on* handler properties, and with a read-only readyState: number.

WebSocketHandleConstructor

type WebSocketHandleConstructor = {
  readonly OPEN: number;
} & (new (...args: never[]) => WebSocketHandle);

The constructor passed to withWebSocketHandle. It must expose the OPEN ready-state constant.

See Client guide.

Edit this page on GitHub
Released under the MIT License. · Copyright © 2026 MunMunMiao

On this page