Documentation menu

@gyral/http

Gyral HTTP driver: fetch requests as data, typed errors, Standard Schema decoding.

npm install @gyral/http

Writing code? Start from working code, not from this page. Effects and drivers. Adapt the closest example and check it, then look a name up here when you are about to call it.

Generated from the package's type declarations. Entry points: @gyral/http, @gyral/http/sse, @gyral/http/websocket, @gyral/http/testing.

@gyral/http

Functions

Name Summary
csrfFromMeta A header source that reads a token from <meta name=…> when each request runs.
get
makeHttpDriver
redirectedTo The location of a FormRedirected answer (formAction in @gyral/ssr), if it is one.
request A command that performs req with the http driver (or its substitute).
retryableHttpError The failures worth retrying: network errors, timeouts, 408, 429 and 5xx.
submitForm Sends a form submission to the server (usually after form() validated it in the browser, which passes the raw FormData).

csrfFromMeta

function csrfFromMeta(meta: string, header?: string): () => Readonly<Record<string, string>>;

A header source that reads a token from <meta name=…> when each request runs. On the server, or when the meta is missing, it adds nothing. The header defaults to x-csrf-token.

get

function get<O = unknown, MS = never, MF = MS>(url: string, handlers: RequestHandlers<O, MS, MF>): Command<MS | MF>;

makeHttpDriver

function makeHttpDriver(options?: HttpDriverOptions): Driver<HttpRequest, unknown, HttpError>;

redirectedTo

function redirectedTo(body: unknown): string | undefined;

The location of a FormRedirected answer (formAction in @gyral/ssr), if it is one.

request

function request<O = unknown, MS = never, MF = MS>(req: Omit<HttpRequest, 'schema' | 'errorSchema'>, handlers: RequestHandlers<O, MS, MF>): Command<MS | MF>;

A command that performs req with the http driver (or its substitute).

retryableHttpError

const retryableHttpError: (error: HttpError) => boolean

The failures worth retrying: network errors, timeouts, 408, 429 and 5xx. Pass it as retry(…, { retryIf: retryableHttpError }). A 4xx other than 408/429 means the request itself is wrong, and decoding errors won't change on a retry. Retry-After isn't read.

submitForm

function submitForm<MS, MF = MS>(url: string, formData: FormData, options: SubmitFormOptions<MS, MF>): Command<MS | MF | IntentRejected>;

Sends a form submission to the server (usually after form() validated it in the browser, which passes the raw FormData). The body is the FormData itself, so the server's formAction parses it exactly like a no-JS post. A 422 answer with an IntentRejected (server-only checks: duplicate email, wrong password) is dispatched as IntentRejected.

Register: (s, m) => [{ ...s, busy: true }, [submitForm('/register', m.form, {
  onSuccess: (body) => ({ _tag: 'Registered', location: redirectedTo(body) }),
  onFailure: () => ({ _tag: 'Failed' }),
})]],

Constants

Name Summary
http The default driver.

http

const http: Driver<HttpRequest, unknown, HttpError>

The default driver. Substitute it by name: el.drivers = { http: makeHttpDriver({...}) }.

Types and interfaces

You rarely write most of these yourself: TypeScript infers them from the functions above. Look one up when an example or a compiler error names it.

Name Summary
FormRedirected A server's JSON answer to a valid form post that would have redirected without JS.
HeaderSource Headers as data, or computed when each request runs (e.g.
HttpDriverOptions
HttpError Every way a request can fail.
HttpMethod
HttpRequest An HTTP request described as data.
RequestHandlers
SubmitFormOptions

FormRedirected

interface FormRedirected {
    readonly _tag: 'Redirected';
    readonly location: string;
}

A server's JSON answer to a valid form post that would have redirected without JS.

HeaderSource

type HeaderSource = Readonly<Record<string, string>> | ((req: HttpRequest) => Readonly<Record<string, string>> | undefined);

Headers as data, or computed when each request runs (e.g. read a CSRF <meta>).

HttpDriverOptions

interface HttpDriverOptions {
    /** Driver name used for substitution (`el.drivers`). Default `'http'`. */
    readonly name?: string;
    /**
     * Default headers for every request through this driver: app-level concerns such as a CSRF
     * token or an API key, so components and stores never read the DOM themselves. This is the
     * one place a CSRF token is configured (ADR 0022). Per-request `headers` win over these.
     *
     *   provideDrivers(document.body, { http: makeHttpDriver({ headers: csrfFromMeta('csrf-token') }) });
     */
    readonly headers?: HeaderSource;
    /** Resolves relative URLs. Default: the document location. */
    readonly baseUrl?: string;
    readonly fetch?: typeof fetch;
    readonly concurrency?: Concurrency;
    /**
     * Aborts a request that takes longer than this, failing with `HttpTimeoutError`. Applies per
     * attempt: under `retry(…)` each attempt gets the full time. Default: no limit.
     */
    readonly timeoutMs?: number;
}

HttpError

type HttpError = {
    readonly _tag: 'HttpStatusError';
    readonly url: string;
    readonly status: number;
    readonly statusText: string;
    /** The response body: parsed JSON, or text, or `undefined` when empty. */
    readonly body: unknown;
    /** `body` decoded by the request's `errorSchema`, when it was given and matched. */
    readonly detail?: unknown;
} | {
    readonly _tag: 'HttpNetworkError';
    readonly url: string;
    readonly message: string;
}
/** The attempt took longer than the driver's `timeoutMs`. */
 | {
    readonly _tag: 'HttpTimeoutError';
    readonly url: string;
    readonly timeoutMs: number;
} | {
    readonly _tag: 'HttpDecodeError';
    readonly url: string;
    readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
};

Every way a request can fail. Delivered to onFailure, never thrown into the view.

HttpMethod

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

HttpRequest

interface HttpRequest {
    readonly url: string;
    readonly method?: HttpMethod;
    readonly headers?: Readonly<Record<string, string>>;
    /** Sent as JSON, except `FormData` and `URLSearchParams`, which are sent as forms. */
    readonly body?: unknown;
    /** Decodes the JSON response. Any Standard Schema library works (Zod, Valibot, Effect…). */
    readonly schema?: StandardSchemaV1;
    /**
     * Decodes the body of a non-2xx response into `HttpStatusError.detail` (for example a 422
     * `IntentRejected`). A body that doesn't match leaves `detail` unset; `body` is always kept.
     */
    readonly errorSchema?: StandardSchemaV1;
}

An HTTP request described as data.

RequestHandlers

interface RequestHandlers<O, MS, MF> {
    /** Decodes the response; its output type becomes `onSuccess`'s input. */
    readonly schema?: StandardSchemaV1<unknown, O>;
    /** Decodes non-2xx bodies into `HttpStatusError.detail` (e.g. a 422 `IntentRejected`). */
    readonly errorSchema?: StandardSchemaV1;
    readonly onSuccess: (body: O) => MS | undefined;
    readonly onFailure?: (error: HttpError) => MF | undefined;
    /** Concurrency lane, e.g. `'search'`. Default `'http'`. */
    readonly key?: string;
    readonly concurrency?: Concurrency;
}

SubmitFormOptions

interface SubmitFormOptions<MS, MF> {
    readonly method?: 'POST' | 'PUT' | 'PATCH';
    /**
     * A CSRF token the app already holds, for this submission only. The header defaults to
     * `x-csrf-token`. A token from a `<meta>` is configured once, on the driver:
     * `makeHttpDriver({ headers: csrfFromMeta('csrf-token') })` (ADR 0022).
     */
    readonly csrf?: {
        readonly token: string;
        readonly header?: string;
    };
    /** The server's success answer, e.g. `{ _tag: 'Redirected', location }` (see `redirectedTo`). */
    readonly onSuccess: (body: unknown) => MS | undefined;
    /** Any failure except a 422 `IntentRejected`, which goes to the `IntentRejected` reducer. */
    readonly onFailure?: (error: HttpError) => MF | undefined;
    /** Concurrency lane. Default `form:<url>`. */
    readonly key?: string;
    /** Default `exhaust`: a second submit while one is in flight is ignored. */
    readonly concurrency?: Concurrency;
}

@gyral/http/sse

Functions

Name Summary
makeEventSourceDriver A cancellable feed; native EventSource owns reconnection, credentials and Last-Event-ID.

makeEventSourceDriver

function makeEventSourceDriver<T>(schema: StandardSchemaV1<unknown, T>, options?: EventSourceOptions): Driver<EventSourceInput, EventSourceOutput<T>, EventSourceError>;

A cancellable feed; native EventSource owns reconnection, credentials and Last-Event-ID.

Types and interfaces

You rarely write most of these yourself: TypeScript infers them from the functions above. Look one up when an example or a compiler error names it.

Name Summary
EventSourceError
EventSourceInput
EventSourceOptions
EventSourceOutput

EventSourceError

interface EventSourceError {
    readonly _tag: 'EventSourceError';
    readonly url: string | undefined;
    readonly message: string;
}

EventSourceInput

interface EventSourceInput {
    readonly url: string;
}

EventSourceOptions

interface EventSourceOptions {
    readonly name?: string;
    readonly withCredentials?: boolean;
    /** Named events in addition to the default message event. No wildcard native listener. */
    readonly events?: readonly string[];
    /** Default JSON parsing. Validation may be asynchronous. */
    readonly parse?: (text: string) => unknown;
}

EventSourceOutput

type EventSourceOutput<T> = {
    readonly _tag: 'EventSourceState';
    readonly url: string;
    readonly state: 'connecting' | 'open' | 'closed';
} | {
    readonly _tag: 'EventSourceMessage';
    readonly url: string;
    readonly eventType: string;
    readonly lastEventId: string;
    readonly data: T;
} | {
    readonly _tag: 'EventSourceDecodeFailed';
    readonly url: string;
    readonly eventType: string;
    readonly lastEventId: string;
    readonly issues: readonly StandardSchemaV1.Issue[];
};

@gyral/http/websocket

Functions

Name Summary
makeWebSocketDriver Typed receive/send/close commands on one owned socket, with bounded outgoing buffering.

makeWebSocketDriver

function makeWebSocketDriver<T, Out>(schema: StandardSchemaV1<unknown, T>, options: WebSocketOptions<Out>): Driver<WebSocketInput<Out>, WebSocketOutput<T>, WebSocketError>;

Typed receive/send/close commands on one owned socket, with bounded outgoing buffering.

Types and interfaces

You rarely write most of these yourself: TypeScript infers them from the functions above. Look one up when an example or a compiler error names it.

Name Summary
WebSocketClosed
WebSocketError
WebSocketIncoming
WebSocketInput
WebSocketOptions
WebSocketOutput
WebSocketPayload

WebSocketClosed

interface WebSocketClosed {
    readonly _tag: 'WebSocketClosed';
    readonly connection: string;
    readonly url: string;
    readonly code: number;
    readonly reason: string;
    readonly wasClean: boolean;
    /** The application issued a valid Close command on this connection. */
    readonly requested: boolean;
}

WebSocketError

type WebSocketError = {
    readonly _tag: 'WebSocketConnectError';
    readonly connection: string | undefined;
    readonly url: string | undefined;
    readonly message: string;
} | {
    readonly _tag: 'WebSocketClosedError';
    readonly connection: string;
    readonly url: string;
    readonly code: number;
    readonly reason: string;
    readonly wasClean: boolean;
    readonly errored: boolean;
    readonly requested: boolean;
} | {
    readonly _tag: 'WebSocketSendError';
    readonly connection: string;
    readonly reason: 'not-open' | 'buffer-limit' | 'encode' | 'send';
    readonly message: string;
    readonly bufferedBytes?: number;
    readonly bytes?: number;
    readonly limit?: number;
} | {
    readonly _tag: 'WebSocketCloseError';
    readonly connection: string;
    readonly message: string;
};

WebSocketIncoming

type WebSocketIncoming = string | ArrayBuffer;

WebSocketInput

type WebSocketInput<T> = {
    readonly _tag: 'Connect';
    readonly connection: string;
    readonly url: string;
    readonly protocols?: string | readonly string[];
} | {
    readonly _tag: 'Send';
    readonly connection: string;
    readonly value: T;
} | {
    readonly _tag: 'Close';
    readonly connection: string;
    readonly code?: number;
    readonly reason?: string;
};

WebSocketOptions

interface WebSocketOptions<T> {
    readonly name?: string;
    readonly encode: (value: T) => WebSocketPayload;
    /** Synchronous parse; default JSON text. Validation may be async. */
    readonly parse?: (payload: WebSocketIncoming) => unknown;
    /** Maximum native bufferedAmount + encoded payload bytes. Default 65536. */
    readonly maxBufferedBytes?: number;
}

WebSocketOutput

type WebSocketOutput<T> = {
    readonly _tag: 'WebSocketState';
    readonly connection: string;
    readonly url: string;
    readonly state: 'connecting' | 'open' | 'closing';
} | {
    readonly _tag: 'WebSocketMessage';
    readonly connection: string;
    readonly data: T;
} | {
    readonly _tag: 'WebSocketDecodeFailed';
    readonly connection: string;
    readonly issues: readonly StandardSchemaV1.Issue[];
} | {
    readonly _tag: 'WebSocketSent';
    readonly connection: string;
    readonly bytes: number;
} | {
    readonly _tag: 'WebSocketCloseRequested';
    readonly connection: string;
} | WebSocketClosed;

WebSocketPayload

type WebSocketPayload = string | Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer>;

@gyral/http/testing

Functions

Name Summary
fakeHttp el.drivers = { http: fakeHttp() } (or provideDrivers(root, { http })), then http.respondNext({ body }) or http.reply(422, problem).

fakeHttp

function fakeHttp(options?: FakeHttpOptions): FakeHttp;

el.drivers = { http: fakeHttp() } (or provideDrivers(root, { http })), then http.respondNext({ body }) or http.reply(422, problem). Request inputs are recorded in requests (alias inputs).

Classes

Name Summary
FakeHttpResponderError The error a fake request fails with when the respond option threw.

FakeHttpResponderError

class FakeHttpResponderError extends Error {
  readonly request: HttpRequest;
  constructor(request: HttpRequest, cause: unknown);
}

The error a fake request fails with when the respond option threw.

Types and interfaces

You rarely write most of these yourself: TypeScript infers them from the functions above. Look one up when an example or a compiler error names it.

Name Summary
FakeHttp
FakeHttpCall One request the fake driver received.
FakeHttpOptions
FakeResponse What a fake server answers: a status (default 200) and a JSON body (omit for none).

FakeHttp

interface FakeHttp extends Driver<HttpRequest, unknown, HttpError> {
    readonly calls: readonly FakeHttpCall[];
    /** Every request so far. */
    readonly requests: readonly HttpRequest[];
    /** Same as `requests`; matches `fakeDriver(…).inputs` from `@gyral/testing`. */
    readonly inputs: readonly HttpRequest[];
    /** Errors thrown (or rejected) by the `respond` option, in order. */
    readonly responderErrors: readonly unknown[];
    /** Answers the oldest request that is still waiting. Throws if there is none. */
    respondNext(response?: FakeResponse): void;
    /** Shorthand for `respondNext({ status, body })`, e.g. `reply(422, problem)`. */
    reply(status: number, body?: unknown): void;
    /** Fails the oldest waiting request as a network error. Throws if there is none. */
    failNext(message?: string): void;
}

FakeHttpCall

interface FakeHttpCall {
    readonly request: HttpRequest;
    readonly signal: AbortSignal;
    readonly settled: boolean;
    /** Answers this request; the real driver then decodes it. */
    readonly respond: (response?: FakeResponse) => void;
    /** Fails this request as a network error (`HttpNetworkError`). */
    readonly fail: (message?: string) => void;
}

One request the fake driver received.

FakeHttpOptions

interface FakeHttpOptions {
    /** Driver name to substitute (`el.drivers`, providers). Default `'http'`. */
    readonly name?: string;
    /** Answer every request immediately. Without it, requests wait for `respondNext`. */
    readonly respond?: (request: HttpRequest) => FakeResponse | Promise<FakeResponse>;
    /** Base URL for relative request URLs. Default `'http://localhost/'`. */
    readonly baseUrl?: string;
    readonly headers?: HeaderSource;
    readonly concurrency?: Concurrency;
    /**
     * Called when the `respond` option throws or rejects. That is a bug in the test, not a
     * network failure, so by default it is rethrown as an uncaught error (Vitest fails the run).
     * The request itself still fails (as `HttpNetworkError`) so the component never hangs.
     */
    readonly onResponderError?: (error: unknown, request: HttpRequest) => void;
}

FakeResponse

interface FakeResponse {
    readonly status?: number;
    readonly statusText?: string;
    readonly body?: unknown;
}

What a fake server answers: a status (default 200) and a JSON body (omit for none).