Configure how your application calls imported SDK operations, independently of their API definitions.
Client
A Client is the consuming application's execution configuration. createClient(...) returns a DerivableClient. Calling the factory with request options returns a Client value, and a Client value is what operations accept. It is not callable. Import operations from your SDK, choose the service address, credentials, interceptors, and transport, then call an operation with the client. getUser(client({ signal }), { id: 1 }) starts a call that declares input. health(client) starts one that does not. When the whole input struct is optional, both search(client) and search(client, input) are valid. There is no third options argument. Calling the operation starts it. await only waits for the error-first tuple.
The SDK's definitions do not bind this instance. A browser, a server request, and a local test can use the same exported operation with different clients. All still follow the same Defjs contract and compatible Core versions. Configure each client's service address for the operations you call through it.
The same client value can be passed to many operations. Applications choose caching and retry policies and own the work they start. HTTP is not retried automatically. Stream reconnection is opt-in.
The factory is a function and is also a Client with no request options. Do not pass the factory to a React state setter: setState(factory) would call it as an updater. Do not use it as a Vue watch or computed source: those call a function. A derived client value is a plain object.
Basic Setup
import { createClient, withEndpoint } from "@defjs/core";
const client = createClient(withEndpoint("https://api.example.com"));createClient(...) returns one callable factory that is also a Client. It is not a set of transport overloads.
Compose options
Options apply left to right. Setters replace; withInterceptors(...items) appends.
import {
createClient,
createHttpInterceptor,
withCredentials,
withEndpoint,
withInterceptors,
} from "@defjs/core";
const audit = createHttpInterceptor(async (request, next) => {
const started = performance.now();
const response = await next(request);
console.info(
request.operation ?? request.method,
response.status,
Math.round(performance.now() - started),
);
return response;
});
const client = createClient(
withEndpoint("https://api.example.com"),
withInterceptors(audit),
withCredentials(true),
);
void client;Mixed interceptors are filtered by transport when the operation runs; relative order among the selected kind stays.
Derive a client
client(options?) returns a Client value for one call. The seven known fields are copied from the argument object when client(options) is called, a top-level snapshot into a fresh options 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 getter that throws escapes client(options) and leaves the factory usable. The client value is not callable; create one per call.
signal is not combined. Reusing a client value that carries signal shares that signal between calls, so create a client value per call. Combine signals with AbortSignal.any in application code.
import { createClient, defineRequest, struct, withEndpoint } from "@defjs/core";
const client = createClient(withEndpoint("https://api.example.com"));
const getUser = defineRequest({
method: "GET",
path: "/users/:id",
input: struct.object({ id: struct.number() }),
builder: (input) => ({ path: input }),
output: struct.object({ id: struct.number(), name: struct.string() }),
});
async function main(signal: AbortSignal) {
const requestClient = client({
signal,
onDownloadProgress(event) {
console.log(event.loaded);
},
});
const [error, user] = await getUser(requestClient, { id: 1 });
if (error) {
console.error(error.code);
return;
}
console.log(user.name);
}
void main;The base configuration from createClient 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. The RequestOptions on a client are that top-level snapshot. Application code passes the client to an operation. It does not read that internal state.
Request options
RequestOptions has seven flat fields. Each protocol reads only the fields it knows and ignores the rest. Passing heartbeat to an HTTP operation 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.
| Field | Used by | Rule |
|---|---|---|
signal | HTTP, SSE, WebSocket | Aborts the call. A deadline is AbortSignal.timeout(ms) (NET_TIMEOUT). A manual abort is NET_ABORTED. |
onUploadProgress | HTTP | Ignored by SSE and WebSocket. |
onDownloadProgress | HTTP | Ignored by SSE and WebSocket. |
beforeConnect | WebSocket | Replaces the client setting for that call. |
protocols | WebSocket | Replaces the client list. It is not appended. |
heartbeat | WebSocket | Replaced whole. timeoutMs is the heartbeat acknowledgement wait, not a request deadline. |
reconnect | WebSocket | Replaced whole. |
There is no timeout field, no abort field, and no client-level default deadline. For SSE and WebSocket, signal covers the whole connection, including after it is open. There is no startup-only deadline. An application that wants one creates its own AbortController, aborts it with new DOMException("...", "TimeoutError") from a timer, and clears the timer after the await returns. See SSE.
HttpExecuteOptions is the HTTP slice: signal plus the progress callbacks. EventStreamExecuteOptions is { signal? }. WebSocketExecuteOptions is the WebSocket slice plus signal.
Results by transport
- HTTP →
[error, data, response] - SSE →
[error, stream, open].openis the returned connection's first successful handshake snapshot for native Defjs handles or wrappers forwarding the same liveopenobject; custom handles or wrappers overridingopenuse their current publicopeneven after reconnect, whilestream.openstays live (see SSE). - WebSocket →
[error, session, connection]
Return before using the success value when error is set.
You own cleanup: abort HTTP, close SSE + await stream.closed, close WebSocket + await session.closed.
Inject a test transport
import {
createClient,
defineRequest,
struct,
withEndpoint,
withHTTPHandle,
} from "@defjs/core";
const getUser = defineRequest({
method: "GET",
path: "/users/:id",
input: struct.object({ id: struct.number() }),
builder: (input) => ({ path: input }),
output: struct.object({ id: struct.number(), name: struct.string() }),
});
const client = createClient(
withEndpoint("https://fixture.invalid"),
withHTTPHandle(async () => Response.json({ id: 7, name: "Ada" })),
);
async function main() {
const [error, user] = await getUser(client, { id: 7 });
if (error) {
console.error(error.code);
return;
}
console.log(user.name);
}
void main();Scope on server vs browser
On a server, create the client inside the request boundary when options or interceptor closures capture auth, cookies, users, or tenants. Client identity isn’t a security boundary by itself.
Migration
| Old | New |
|---|---|
client.execute(getUser(input), options) | getUser(client(options), input) |
execute(cmd, { timeout: 5000 }) | client({ signal: AbortSignal.timeout(5000) }) |
execute(cmd, { signal }) | client({ signal }) |
client({ signal })({ onDownloadProgress }) | client({ signal, onDownloadProgress }) |
createClient(withTimeout(5000), ...) | pass signal at the call, or wrap an application function |
Reference
| Helper | Effect |
|---|---|
withEndpoint(url) | Absolute base endpoint for all transports |
withHeaders(HeadersInit) | Static default headers for HTTP/SSE; builder headers override |
withHTTPHandle(fetch) | Replace Fetch for HTTP |
withSSEHandle(fetch) | Replace Fetch for SSE |
withWebSocketHandle(WebSocket) | Replace WebSocket constructor |
withInterceptors(...items) | Append mixed interceptors |
withQuerySerializer(fn) | Encode an already-aliased query object into a query string |
withCredentials(boolean) | Fetch credentials: 'include' for HTTP/SSE when true |
withXSRF(options?) | HTTP XSRF cookie → header |
withSSEReconnect / withSSEOnInvalidEvent | SSE knobs |
withWebSocketReconnect / withWebSocketHeartbeat / withWebSocketProtocols / withWebSocketBeforeConnect | WebSocket knobs |