Defjs

Design HTTP operations for your SDK and call them with application-owned configuration and cancellation.

HTTP

The SDK defines each HTTP operation's method, relative path, input mapping, response, and error contract. The application imports that operation, configures its own client, calls it, and handles the result. The same definition can be shipped in an npm package or shared locally.

Keep service addresses and credentials in client configuration. The caller owns cancellation and any policy for retrying a write; a cancelled request may already have reached the service.

Basic Setup

import { createClient, defineRequest, struct, withEndpoint } 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://api.example.com"));

async function main() {
  const [error, user] = await getUser(client, { id: 1 });
  if (error) {
    console.error(error.code);
    return;
  }
  console.log(user.name);
}

void main();

Resolve the URL

withEndpoint(...) needs a valid absolute URL. Endpoint pathname stays as a directory; query and hash are discarded before the request URL is resolved. A missing, relative, or invalid base endpoint fails with REQ_BUILD_FAILED during request building, before any interceptor or transport runs, even if an interceptor would short-circuit the request. HTTP, SSE, and WebSocket share this rule.

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

const client = createClient(withEndpoint("https://api.example.com/v1"));
const getUser = defineRequest({
  method: "GET",
  path: "/users/:id",
  input: struct.object({
    id: struct.string(),
    fields: struct.string().optional(),
  }),
  builder: (input) => ({
    path: { id: input.id },
    query: { fields: input.fields },
  }),
});

// → https://api.example.com/v1/users/a%2Fb?fields=name

async function main() {
  const [error] = await getUser(client, { id: "a/b", fields: "name" });
  if (error) {
    console.error(error.code);
    return;
  }
}

void main();

Path placeholders are raw scalars, encoded exactly once. Empty values and . / .. are rejected. Slashes, ?, #, %, spaces, and Unicode in one placeholder stay one encoded segment — don’t pre-encode.

A placeholder name starts at : and runs to the next / or the end of the path: /files/:name.json names the parameter name.json, not name. A literal colon in a static segment is not supported.

Definition paths cannot be scheme-like or protocol-relative (including with leading spaces), or contain C0 control characters (including tab, CR, and LF), backslashes, ?, or #. A plain inner space is allowed and sent as %20. After resolution, the URL must keep the base origin and stay under its directory; a path such as ../users cannot escape it. Illegal paths fail with REQ_BUILD_FAILED during building, before transport. An interceptor's final req.endpoint is checked against its final req.baseEndpoint with the same rule; an interceptor may deliberately replace the base with another valid absolute URL. Deterministic URL errors are never retried.

The default query encoder accepts scalars and arrays of scalars (arrays repeat the key). A nested object fails with REQ_BUILD_FAILED unless the client has withQuerySerializer(...). null in path, query, or headers fails the same way. undefined query and header values are omitted. An undefined or missing path placeholder fails with REQ_BUILD_FAILED.

Place the input

The struct encodes the value. The builder places it. A declared input always requires an explicit builder. input only defines the parameters; the builder alone decides where each value goes. Only definitions without input may omit builder.

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

const client = createClient(withEndpoint("https://api.example.com"));
const updateUser = defineRequest({
  method: "PATCH",
  path: "/users/:id",
  input: struct.object({
    id: struct.number(),
    requestId: struct.string(),
    profile: struct.object({
      displayName: struct.string().alias("display_name"),
    }),
  }),
  builder: (input) => ({
    path: { id: input.id },
    headers: { "x-request-id": input.requestId },
    body: input.profile,
  }),
  output: struct.object({
    id: struct.number(),
    displayName: struct.string().alias("display_name"),
  }),
});

async function main() {
  const [error, user] = await updateUser(client, {
    id: 7,
    requestId: "request-42",
    profile: { displayName: "Ada" },
  });
  if (error) {
    console.error(error.code);
    return;
  }
  console.log(user.id);
}

void main();

That body is {"display_name":"Ada"} with application/json. The header wire name is x-request-id. Callers and parsed results keep displayName.

Prefer handing the typed value over spelling wire keys. Alias is declared once on the field and applies in both directions: output / error / events / incoming frames decode display_name to displayName, and body: input.order rewrites the whole tree. The same User struct can be a create body and a get output. A hand-written key is the exception: the parent object is anonymous, so the alias does not run.

Builder expressionOn the wire
body: input.profileTyped object: alias and Date / bigint encoding
body: { profile: input.profile }Outer keys literal, inner object still typed
body: { ...input.profile, role: 'admin' }Spread keeps the struct; the extra key is literal
body: { name: input.profile.displayName }Anonymous parent: alias does not apply
query: input.filter / headers: input.traceTyped flat objects: alias becomes the wire key

Spreading a typed object and also setting its wire key (display_name next to displayName) is REQ_BUILD_FAILED. A nested object in path or headers fails the same way.

The guide is chosen at every node, not only at the root. A new object under an anonymous parent keeps the keys you wrote. The same object under a typed parent uses that field's struct.

body: { row: { id: '42' } }              // Anonymous root: emits id
body: { ...input, row: { id: '42' } }    // Typed root, row field declares alias: emits wire_id

When a union guide matches more than one branch, each branch is encoded and compared. Identical wire output is kept; a difference is REQ_BUILD_FAILED.

A mixed intersection that contains a non-object part is tagged, when parsed, with the conjunction of the tags from the parts that matched; a newly written mixed-intersection value that was not parsed is an anonymous object.

Query

Nested query shape (a[b], a.b, comma lists) is a backend convention, so the hook is on the client:

import { createClient, withEndpoint, withQuerySerializer } from "@defjs/core";

const client = createClient(
  withEndpoint("https://api.example.com"),
  withQuerySerializer((query) => {
    const params = new URLSearchParams();
    const append = (key: string, value: unknown) => {
      if (Array.isArray(value)) {
        for (const item of value) append(key, item);
        return;
      }
      if (value !== null && typeof value === "object") {
        for (const [child, nested] of Object.entries(value)) {
          append(`${key}[${child}]`, nested);
        }
        return;
      }
      if (value !== undefined) params.append(key, String(value));
    };
    for (const [key, value] of Object.entries(query)) append(key, value);
    return params.toString();
  }),
);

query here is the encoded wire object. Aliases are already applied, so the serializer sees wire keys. The returned string is stored as queryString, then assigned to URL.search, which percent-encodes spaces, #, quotes, and non-ASCII. Defjs does not ship a nested-query encoder.

Without a serializer, arrays repeat keys and a nested object is REQ_BUILD_FAILED. Returning URLSearchParams from the builder uses that value as-is and skips the serializer. The serializer is not called when there is no query. Interceptors then see queryParams as new URLSearchParams(queryString).

Body

The first match wins:

body valueSent asDefault content type
omitted / undefinedno body—
typed object whose root has a urlencoded or formData codecURLSearchParams / FormData; keys are wire names; arrays repeat keysform type / platform boundary
FormData, URLSearchParamsas-issame; Defjs clears a stale Content-Type on FormData
Blob / Fileas-isblob.type, or application/octet-stream
ArrayBuffer, TypedArray, DataViewas-isapplication/octet-stream
ReadableStreamstreamed (duplex: 'half' when the runtime allows it)application/octet-stream
stringas-istext/plain;charset=UTF-8
plain object, array, number, boolean, null, Date, bigintencoded, then JSON.stringifyapplication/json

HTTP keeps the builder body's inferred type in the plain request.bodyContentType field, which survives { ...req } copies; it never writes that inference into request.headers. An explicit content-type header always wins, whether set by the builder, client defaults, an interceptor, or a raw fetchHandler request, including when upload progress converts the body to a stream. bodyContentType applies only while body is the builder's value; a replacement body is detected again, so an interceptor declares its type with a content-type header. body: undefined sends no library-inferred Content-Type. FormData is the exception: its content type must use the boundary from the actual serialization. The codec tag applies only at the body root: body: { meta: input.form } is JSON, and the form fields are ordinary JSON fields. A string is raw text; a JSON string such as "abc" needs JSON.stringify plus application/json. undefined fields are omitted; a nullable field sent as null is JSON null.

JSON must be JSON data all the way down: plain objects, arrays, strings, finite numbers, booleans, and null, plus values whose toJSON() returns those, such as Date or URL. Anything else, at the root or nested, fails with REQ_BUILD_FAILED before anything is sent, instead of being sent as {}, null, or no body at all: a Map or Set, a class instance without toJSON, a function, NaN, a Node Readable, or an iterable or async iterable, including an object literal with a Symbol.iterator or Symbol.asyncIterator method. The message names the field. An iterable object given as query or headers fails the same way instead of being dropped; pass header pairs as an array. Send a stream as a Web ReadableStream: ReadableStream.from(iterable) or Readable.toWeb(readable). The body type rejects most of these at compile time; an interface value or class instance needs { ...value }.

GET and HEAD cannot return a body — that is a type error. WebSocket builders cannot return headers or body.

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

const login = defineRequest({
  method: "POST",
  path: "/login",
  input: struct.object({
    form: struct.urlencoded({
      userName: struct.string().alias("username"),
      password: struct.string(),
    }),
  }),
  builder: (input) => ({ body: input.form }),
});

const upload = defineRequest({
  method: "POST",
  path: "/files/:id",
  input: struct.object({
    id: struct.string(),
    photos: struct.array(struct.file()),
  }),
  builder: (input) => {
    const body = new FormData();
    input.photos.forEach((photo, index) =>
      body.append("photo", photo, `photo-${index}.jpg`),
    );
    return { path: { id: input.id }, body };
  },
});

void login;
void upload;

JSON.stringify, qs, or protobuf that you run yourself sees logical names. Alias does not apply on that path. There is no public struct.encode.

A builder may branch on the real input and return a different body per case (JSON records, a File, or URLSearchParams). Native Headers are also passed through and are not re-encoded.

How a body is read

output is one Struct for the 2xx body; error is one Struct for everything else. The struct kind selects the representation: string is raw text, blob / file are blobs, arrayBuffer is bytes, and every other kind is JSON. With neither side declared, the body is never read.

Order of operations:

  1. ok picks the side: output for 2xx, error for everything else. Never both.
  2. Nothing declared for that side → the body is never read. 2xx succeeds with data === undefined; non-2xx is HTTP_STATUS with data === undefined. An unreadable body on a side you did not declare is ignored, including the fact that it could not be read.
  3. For a JSON representation, the media type is checked before the body is read. A mismatch is RES_MEDIA_TYPE_INVALID and nothing is parsed or decoded. Text, blob, file, and bytes skip this check.
  4. The representation is read. A failure is RES_DECODE_FAILED. JSON from the transport is parsed once: a string on that value is a JSON string root and is not parsed again; bytes and blobs are raw and are parsed once.
  5. The Struct parses the value. A failure is RES_STRUCT_MISMATCH.
  6. 2xx → result and a typed response.body; non-2xx → typed data on HTTP_STATUS.

The built-in Fetch transport performs steps 3 and 4 for a declared side while the response is still inside the interceptor chain, so interceptors receive a response whose body is already the parsed JSON, text, Blob, or bytes. A media-type or read failure rejects next() as BodyFailure before the chain resolves (RES_MEDIA_TYPE_INVALID, RES_DECODE_FAILED, or NET_BODY_INCOMPLETE when the stream breaks). Step 5 runs once, after the chain. makeResponse(...) skips that in-chain read. Its body is decoded only after the chain, in the same step as Struct validation. On a JSON representation, a string body is parsed there; a value the transport already parsed is not parsed again.

A successful response is a DecodedResponse<T>: url, status, statusText, headers, ok, and a typed body. Where nothing was decoded you get HttpMeta — the same fields without body. ok means only 200 <= status < 300. Neither is a native Response. A transport failure is a fault, so there is no status-0 response standing in for one.

Cancel the work

Pass signal on a derived client. A deadline is AbortSignal.timeout(ms) and classifies as NET_TIMEOUT. A manual abort classifies as NET_ABORTED. There is no timeout field, no abort field, and no client-level default deadline. signal is the call's signal; combine signals with AbortSignal.any in application code. Combine a caller signal with a deadline using 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() {
  const [error, user] = await getUser(
    client({ signal: AbortSignal.timeout(5_000) }),
    { id: 1 },
  );
  if (error?.code === "NET_TIMEOUT") {
    console.error("timed out");
    return;
  }
  if (error) {
    console.error(error.code);
    return;
  }
  console.log(user.name);
}

void main();
import { createClient, defineRequest, withEndpoint } from "@defjs/core";

const client = createClient(withEndpoint("https://api.example.com"));
const getReport = defineRequest({ method: "GET", path: "/report" });

async function main() {
  const controller = new AbortController();
  const pending = getReport(
    client({
      signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
    }),
  );

  controller.abort("screen closed");
  const [error] = await pending;
  if (error?.code === "NET_ABORTED") {
    console.log("caller cancellation");
    return;
  }
  if (error) {
    console.error(error.code);
    return;
  }
}

void main();

Recognized cancel → NET_ABORTED; a deadline abort → NET_TIMEOUT; interceptor throw → EXT_INTERCEPTOR_FAILED, except ERR_ABORTED / ERR_TIMEOUT, which give NET_ABORTED / NET_TIMEOUT; other Fetch failures → NET_UNREACHABLE. Cancel after the server accepted a write does not prove the write rolled back.

Credentials and XSRF

withCredentials(true) sets Fetch credentials: 'include' for HTTP and SSE. It does not create Authorization and does not configure WebSocket auth. false leaves credentials unspecified.

withXSRF(...) is HTTP-only. Defaults: cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN'. Header injects only for non-safe methods and only when the caller didn’t already set it. Whenever location exists, including in a Worker, the resolved request URL must match location.origin; a cross-origin request does not call tokenProvider. Cookie lookup separately requires document. Skips GET, HEAD, OPTIONS, TRACE. In a Worker or server, pass a synchronous request-scoped tokenProvider if you need injection.

Keep credentials, XSRF tokens, and query strings out of routine logs. Don’t use query params as a general credential channel.

Progress and the Fetch boundary

onDownloadProgress runs while a declared response body is read. loaded counts bytes read from the response stream, which Fetch may already have decompressed. lengthComputable is true only with a positive Content-Length and an absent or identity Content-Encoding. Other encodings, including gzip and br, report total: 0 and lengthComputable: false, so decoded bytes are never compared with a compressed wire length. No struct for that side → the body is not read → no body-read progress.

onUploadProgress reports progress for a ReadableStream<Uint8Array> body as Fetch reads it, not when the server acknowledges it. Upload progress is reported only where the runtime can stream request bodies: in Bun and Node, Defjs also converts Blob / File, ArrayBuffer, and FormData bodies to a stream, so they report progress too. On server runtimes, FormData with onUploadProgress uses the serialized stream directly, preserves its multipart boundary, and is sent without a library-generated Content-Length (chunked over HTTP/1.1), so progress and abort work without buffering the whole body. Its total is unknown unless a valid explicit Content-Length supplies it. For an endpoint that requires Content-Length, such as some presigned upload targets, omit onUploadProgress; platform Fetch then sends FormData with its length. Cancellation releases the wrapped stream. Browsers, including Workers, are excluded, because Chromium streams a request body only over HTTP/2 or later and Firefox cannot stream one at all; there those bodies are sent unchanged and onUploadProgress is not called. JSON, text, URLSearchParams, and typed-array bodies are sent whole and produce no events. total is 0 (so lengthComputable is false) for a stream without a known length. A ReadableStream body on a runtime that cannot stream requests, such as Firefox, fails with ENV_UNSUPPORTED before anything is sent; the cause message is ERR_STREAMING_REQUEST_UNSUPPORTED. In Chromium, a ReadableStream body needs HTTP/2 or later; over HTTP/1.1 the call fails with NET_UNREACHABLE. onDownloadProgress behaves the same in every runtime.

A progress callback that throws or rejects fails the call with EXT_OBSERVER_FAILED and the thrown value as cause, for downloads and uploads alike. Caller cancellation still reports NET_ABORTED, or NET_TIMEOUT when the abort reason is a timeout.

fetchHandler(httpRequest, fetchImpl?) is the lower-level Fetch boundary: resolves the URL, calls Fetch, reads the representation, and returns HttpResponse. The default HTTP and SSE handles resolve globalThis.fetch on each call, so a replacement installed after import is used. HTTP uses (url, init) only for the untouched platform Fetch; a replaced global or custom handle receives a native Request. Importing Core without Fetch is valid; a default HTTP/SSE call fails with ENV_UNSUPPORTED if Fetch is still missing, while custom handles remain usable. This boundary does not validate operation input, dispatch output, or run interceptors. Useful for injected transport tests — not a substitute for calling the operation.

Replay limits

Defjs does not auto-retry HTTP. Retrying a read still needs a reviewed timeout/network/duplicate policy. Retrying a mutation needs replayable bytes, server support, an idempotency key bound to auth scope + request bytes, and a receiver duplicate policy.

A client or Fetch boundary can’t know if a failed write committed. Keep replay decisions in the app or a reviewed interceptor. Interceptors can short-circuit or replace the low-level request; the final status and body must still satisfy the operation’s contract.

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

On this page