Libraries v5.0.0
    Preparing search index...

    Module @cleverbrush/client - v5.0.0

    @cleverbrush/client

    Unit coverage

    Typed HTTP client for @cleverbrush/server API contracts — zero codegen, full type safety. Optional React + TanStack Query integration via @cleverbrush/client/react.

    decodeValidationIssues(error, { source: 'body' }) recognizes ApiError validation Problem Details with status 400 or 422. It returns plain { pointer, detail }[], structurally compatible with React Form's FormIssue, or undefined for unrelated/malformed errors. No React import is needed.

    import { decodeValidationIssues } from '@cleverbrush/client';
    try {
    await client.profiles.save({ body: values });
    } catch (error) {
    const issues = decodeValidationIssues(error, { source: 'body' });
    if (issues) return { ok: false, error: 'Check your input.', issues };
    throw error; // Or translate expected failures into an application-safe message.
    }

    The source is required (body, query, or headers). Only that prefix is stripped: /body/addresses/0/city becomes /addresses/0/city. Other sources, unknown request roots and root errors become empty-pointer form-level issues, not silently dropped fields. JSON Pointer escapes are validated and preserved. Malformed entries reject the entire payload; retain a safe general-error fallback. The errors collection is Framework's extension, not a universal Problem Details standard. Business messages and network exceptions are never guessed into fields.

    See the multi-file action/form example for serialization boundaries and the form issue lifecycle.

    An endpoint's upload schema determines its files argument. Single fields accept File, Blob, or FilePart; array fields accept arrays of those values.

    import { createClient } from '@cleverbrush/client';
    import { defineApi, endpoint, file } from '@cleverbrush/server/contract';
    import { array, object } from '@cleverbrush/schema';

    const api = defineApi({ assets: {
    upload: endpoint.post('/assets').upload(object({
    images: array(file()).minLength(1),
    cover: file().optional()
    }))
    } });
    const client = createClient(api);
    await client.assets.upload({ files: {
    images: [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')]
    } });

    File-only calls need no body argument. The client serializes arrays as repeated multipart fields in order, omits undefined optional fields, and lets FormData set the content-type boundary. Use File or FilePart to supply a filename; a plain Blob uses the platform's default filename. Text fields remain in the endpoint's separate body argument.

    @cleverbrush/client provides a Proxy-based HTTP client that infers all endpoint types (params, body, query, headers, responses) from an API contract defined with defineApi() from @cleverbrush/server/contract. No code generation or manual type annotations are needed.

    The optional /react subpath adds TanStack Query hooks to the same client, so every endpoint is both a callable function and a hook provider.

    // packages/shared/src/contract.ts
    import { defineApi, endpoint, route } from '@cleverbrush/server/contract';
    import { array, number, object, string, boolean } from '@cleverbrush/schema';

    const TodoSchema = object({ id: number(), title: string(), completed: boolean() });
    const todosResource = endpoint.resource('/api/todos');
    const ById = route({ id: number().coerce() })`/${t => t.id}`;

    export const api = defineApi({
    todos: {
    list: todosResource.get()
    .query(object({ page: number().optional(), limit: number().optional() }))
    .responses({ 200: array(TodoSchema) }),
    get: todosResource.get(ById)
    .responses({ 200: TodoSchema }),
    create: todosResource.post()
    .body(object({ title: string() }))
    .responses({ 201: TodoSchema }),
    delete: todosResource.delete(ById)
    .responses({ 204: null }),
    },
    auth: {
    login: endpoint.post('/api/auth/login')
    .body(object({ email: string(), password: string() }))
    .responses({ 200: object({ token: string() }) }),
    },
    });
    // Framework-agnostic (works in any JS runtime)
    import { createClient } from '@cleverbrush/client';
    import { api } from 'shared/contract';

    export const client = createClient(api, {
    baseUrl: 'https://api.example.com',
    getToken: () => localStorage.getItem('token'),
    onUnauthorized: () => { window.location.href = '/login'; },
    });

    Or with React + TanStack Query hooks:

    // React — adds useQuery, useMutation, etc. to every endpoint
    import { createClient } from '@cleverbrush/client/react';
    import { api } from 'shared/contract';

    export const client = createClient(api, {
    baseUrl: 'https://api.example.com',
    getToken: () => localStorage.getItem('token'),
    });
    // Direct fetch (both clients)
    const todos = await client.todos.list({ query: { page: 1, limit: 10 } });
    // ^? TodoResponse[]

    const todo = await client.todos.get({ params: { id: 1 } });
    // ^? TodoResponse

    const created = await client.todos.create({ body: { title: 'Buy milk' } });
    // ^? TodoResponse

    await client.todos.delete({ params: { id: 1 } });
    // ^? undefined (204 No Content)
    // React hooks (react client only)
    function TodoList() {
    const { data, isLoading } = client.todos.list.useQuery();

    if (isLoading) return <p>Loading…</p>;
    return <ul>{data?.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
    }

    Creates a typed HTTP client from an API contract.

    Parameters:

    Parameter Type Description
    contract ApiContract Contract created with defineApi()
    options.baseUrl string Base URL prepended to every request (default: '')
    options.getToken () => string | null Returns auth token for Authorization: Bearer header
    options.onUnauthorized () => void Called on 401 responses
    options.fetch typeof fetch Custom fetch implementation (default: globalThis.fetch)
    options.headers Record<string, string> Extra headers sent with every request
    options.middlewares Middleware[] Middleware functions that wrap the fetch call
    options.hooks ClientHooks Lifecycle hooks invoked at various stages of a request

    Middlewares wrap the fetch call, allowing you to intercept, modify, or short-circuit requests and responses. They compose like an onion — the first middleware in the array is the outermost wrapper.

    import { createClient } from '@cleverbrush/client';
    import { retry } from '@cleverbrush/client/retry';
    import { timeout } from '@cleverbrush/client/timeout';
    import { dedupe } from '@cleverbrush/client/dedupe';
    import { throttlingCache } from '@cleverbrush/client/cache';

    const client = createClient(api, {
    baseUrl: 'https://api.example.com',
    middlewares: [
    retry({ limit: 3 }),
    timeout({ timeout: 10000 }),
    dedupe(),
    throttlingCache({ throttle: 2000 }),
    ],
    });
    import type { Middleware } from '@cleverbrush/client';

    const logger: Middleware = (next) => async (url, init) => {
    console.log('→', init.method, url);
    const res = await next(url, init);
    console.log('←', res.status);
    return res;
    };

    Hooks are invoked at various stages of a request. All hook arrays execute serially in order.

    const client = createClient(api, {
    hooks: {
    beforeRequest: [(req) => {
    req.init.headers = {
    ...req.init.headers as Record<string, string>,
    'X-Request-Id': crypto.randomUUID(),
    };
    }],
    afterResponse: [(req, res) => {
    console.log(`${req.init.method} ${req.url} → ${res.status}`);
    }],
    beforeError: [(error) => {
    console.error('Request failed:', error.message);
    return error;
    }],
    },
    });
    Hook Signature Description
    beforeRequest (req: { url, init }) => void Modify request before sending
    afterResponse (req, response) => void | Response Inspect/replace response
    beforeRetry (info: { url, init, error, retryCount }) => void Called between retry attempts
    beforeError (error: WebError) => WebError Transform errors before throwing

    For endpoints declared with .idempotent(), the typed client generates one X-Idempotency-Key per call before middleware execution. retry() recognizes the contract and reuses the key/body for every HTTP attempt; other POSTs retain their existing retry policy. Ordinary endpoint calls require no extra options:

    await client.items.create({ body: item });
    

    Explicit middleware or per-call retry.methods takes precedence, and retry: { limit: 0 } disables automatic retries. batching() sends these calls directly so their individual timeout signals still reach the transport. Each new client invocation gets a fresh key, even when its input is identical. This does not track form submissions or user-initiated retries across calls.

    The server requires an authorization scope and replays responses within a bounded, process-local store. This does not provide durable exactly-once execution across replicas or restarts.

    import { retry } from '@cleverbrush/client/retry';

    retry({
    limit: 2, // max retries (default: 2)
    methods: ['GET'], // retryable methods (default: GET, PUT, HEAD, DELETE, OPTIONS)
    statusCodes: [500], // retryable status codes (default: 408, 429, 500, 502, 503, 504)
    backoffLimit: 5000, // max delay in ms (default: Infinity)
    delay: (n) => n * 1000, // custom delay function
    jitter: true, // add randomization to delays
    retryOnTimeout: false, // retry on TimeoutError (default: false)
    shouldRetry: (err, count) => count < 3, // custom predicate
    });

    Respects Retry-After headers (seconds and HTTP-date formats) on 429/503 responses.

    import { timeout } from '@cleverbrush/client/timeout';

    timeout({ timeout: 10000 }); // 10 second timeout (default)

    Aborts requests that exceed the configured duration, throwing a TimeoutError.

    import { dedupe } from '@cleverbrush/client/dedupe';

    dedupe({
    skip: (url, init) => init.method !== 'GET', // skip non-GET (default)
    key: (url, init) => `${init.method}@${url}`, // dedup key (default)
    });

    Prevents duplicate in-flight requests. Concurrent calls with the same key share a single fetch; each caller receives a cloned response.

    import { throttlingCache } from '@cleverbrush/client/cache';

    throttlingCache({
    throttle: 1000, // TTL in ms (default: 1000)
    skip: (url, init) => init.method !== 'GET', // skip non-GET (default)
    condition: (res) => res.ok, // only cache successful responses (default)
    invalidate: (url, init) => {
    if (init.method !== 'GET') return `GET@${url}`;
    return null;
    },
    });

    Caches successful GET responses for a configurable TTL. Subsequent requests within the TTL receive a cloned cached response without hitting the network.

    Tag-based HTTP caching with automatic invalidation driven by server-side endpoint annotations (.cacheTag() / .clearsCacheTag()). Replaces manual invalidation callbacks — mutations automatically clear cache entries matching the endpoint's declared tag names.

    import { cacheTags } from '@cleverbrush/client/cache';

    const client = createClient(api, {
    middlewares: [cacheTags({ defaultTtl: 5000 })],
    });

    // Populates versioned keys for the 'todo-list' and 'todo' tag definitions.
    await client.todos.list({ query: { page: 1 } });
    await client.todos.get({ params: { id: 1 } });

    // Mutation — automatically invalidates both tags.
    await client.todos.update({ params: { id: 1 }, body: { title: 'Updated' } });

    // Triggers network fetch — cache was cleared.
    await client.todos.list({ query: { page: 1 } });
    Option Type Default Description
    defaultTtl number 0 TTL in ms for tags without explicit TTL. 0 = invalidation-only.
    ttlByTag Record<string, number> {} Per-tag TTL overrides.
    condition (Response) => boolean response.ok Predicate controlling which responses are cached.

    When used with @cleverbrush/client/react, TanStack Query's useMutation hooks automatically invalidate the query cache for the affected group — no manual queryClient.invalidateQueries() needed.

    See the server-side cache tags section for how to declare tags on your endpoints.

    Use externalCacheTags() to bridge endpoint .clearsCacheTag() metadata to any cache system that accepts tag invalidation:

    import { createClient } from '@cleverbrush/client';
    import { externalCacheTags } from '@cleverbrush/client/cache';

    const client = createClient(api, {
    middlewares: [
    externalCacheTags({
    invalidateTag: async tag => externalCache.invalidate(tag),
    }),
    ],
    });

    For Next.js, pass revalidateTag as the invalidation callback:

    import { revalidateTag } from 'next/cache';

    externalCacheTags({ invalidateTag: revalidateTag });

    The middleware runs after successful POST, PUT, PATCH, and DELETE responses. Tags invalidate both the literal base tag name and the versioned computed key by default, for example expense and ct2:["expense",[["id",["number","42"]]]]. Property-free tags also have a computed key: ct2:["expense",[]].

    Computed keys share a deterministic, type-tagged encoder with the server; property/object key order is normalized and dates retain millisecond precision. Only successful mutations invalidate the in-memory cache; older in-flight reads cannot refill invalidated entries or aliases. Tag-name-prefix coverage and TTLs are unchanged. Unsupported selected values throw TypeError.

    Breaking migration: upgrade all external cache writers and invalidators together and retire old entries. Base invalidation labels remain literal names, but all computed keys changed. Endpoint and auth/tenant scope are still consumer-defined. See the migration guide.

    Override middleware options for individual calls:

    // Override timeout for a slow endpoint
    const report = await client.reports.generate({
    body: { type: 'annual' },
    timeout: 60000,
    });

    // Override retry limit
    const data = await client.data.fetch({
    query: { id: 1 },
    retry: { limit: 5 },
    });

    All errors extend a common WebError base class.

    import {
    ApiError, TimeoutError, NetworkError,
    isApiError, isTimeoutError, isNetworkError, isWebError
    } from '@cleverbrush/client';
    Error Description Properties
    WebError Base class for all web client errors message
    ApiError Non-2xx HTTP response status, message, body
    TimeoutError Request exceeded timeout timeout (ms)
    NetworkError Network-level failure cause
    try {
    await client.todos.get({ params: { id: 999 } });
    } catch (err) {
    if (isApiError(err)) {
    console.log(err.status, err.body);
    } else if (isTimeoutError(err)) {
    console.log('Timed out after', err.timeout, 'ms');
    } else if (isNetworkError(err)) {
    console.log('Network failure:', err.cause);
    }
    }
    import { batching } from '@cleverbrush/client/batching';
    

    Reduces HTTP round-trips by coalescing concurrent requests into a single POST /__batch. Individual call sites are completely unaware of batching — they still receive their own typed responses.

    Prerequisite: the server must have batching enabled via ServerBuilder.useBatching().

    import { batching } from '@cleverbrush/client/batching';

    const client = createClient(api, {
    baseUrl: 'https://api.example.com',
    middlewares: [
    retry(),
    timeout(),
    batching({ maxSize: 10, windowMs: 10 }), // innermost — closest to fetch
    ],
    });

    // These three concurrent calls are coalesced into ONE HTTP request.
    const [todos, user, stats] = await Promise.all([
    client.todos.list(),
    client.users.me(),
    client.stats.summary(),
    ]);
    1. The first queued request starts a windowMs timer.
    2. Additional requests arriving before the timer fires join the same batch.
    3. When the timer fires (or maxSize is reached), all queued requests are sent as a single POST /__batch.
    4. The server processes each sub-request through its full pipeline and returns an array of sub-responses.
    5. Each caller receives its own reconstructed Response.

    If only one request is queued at flush time it is sent directly — no batch overhead.

    Option Type Default Description
    maxSize number 10 Maximum requests per batch; flush immediately on reaching this limit
    windowMs number 10 Collection window in milliseconds
    batchPath string '/__batch' Batch endpoint path (must match server config)
    skip (url, init) => boolean — Return true to bypass batching for a specific request
    batching({
    skip: (_url, init) => {
    // Never batch file uploads
    return init.body instanceof FormData;
    },
    })

    Place batching() last in the middleware array so that retry() and timeout() operate on each logical call promise independently, not on the single batch fetch:

    middlewares: [retry(), timeout(), batching()], // ✅ correct
    middlewares: [batching(), retry(), timeout()], // ⚠️ retry wraps the whole batch

    Subscription endpoints defined with endpoint.subscription() in the server contract are automatically detected by the client. Instead of returning a Promise, they return a live Subscription handle backed by the browser WebSocket API.

    // The subscribe function returns a Subscription handle, not a Promise
    const sub = client.live.todoUpdates();

    // Consume events via async iteration
    for await (const event of sub) {
    console.log(event.action, event.todoId);
    }
    const chat = client.live.chat();

    // Send messages to the server
    chat.send({ text: 'Hello!' });

    // Receive messages
    for await (const msg of chat) {
    console.log(`${msg.user}: ${msg.text}`);
    }
    const sub = client.live.events();

    sub.state; // 'connecting' | 'connected' | 'reconnecting' | 'closed'
    sub.close();
    
    const ac = new AbortController();
    const sub = client.live.events({ signal: ac.signal });

    // Abort closes the WebSocket
    ac.abort();

    Auth tokens are sent as a ?token= query parameter (the browser WebSocket API does not support custom headers):

    const client = createClient(api, {
    baseUrl: 'https://api.example.com',
    getToken: () => localStorage.getItem('token'),
    });

    // Token is automatically appended:
    // wss://api.example.com/ws/events?token=<token>
    const sub = client.live.events();

    Enable automatic reconnection with exponential backoff for WebSocket subscriptions. Reconnection is not triggered by manual .close() calls or AbortSignal aborts — only by unexpected connection drops.

    const client = createClient(api, {
    baseUrl: 'https://api.example.com',
    subscriptionReconnect: {
    maxRetries: 10, // default: Infinity
    backoffLimit: 30_000, // max delay ms (default: 30 000)
    jitter: true, // ±25% random jitter (default: true)
    },
    });
    // Override the global default for this subscription:
    const sub = client.live.events({
    reconnect: { maxRetries: 3, jitter: false },
    });

    // Disable reconnection for this subscription even when a global default is set:
    const sub = client.live.events({ reconnect: false });

    // Use global defaults explicitly:
    const sub = client.live.events({ reconnect: true });
    const sub = client.live.events({
    reconnect: {
    delay: (attempt) => Math.min(500 * 2 ** (attempt - 1), 60_000),
    jitter: false,
    },
    });

    The default delay formula is 300 × 2^(attempt − 1) ms (same as the HTTP retry middleware), capped by backoffLimit.

    const sub = client.live.events({
    reconnect: {
    shouldReconnect: ({ code, reason }) => code !== 4003,
    },
    });

    When shouldReconnect returns false the subscription transitions directly to 'closed' without further attempts.

    const { state } = useSubscription(() => client.live.events({
    reconnect: { maxRetries: 5 },
    }));

    // state can be: 'connecting' | 'connected' | 'reconnecting' | 'closed'
    if (state === 'reconnecting') {
    return <Badge>Reconnecting…</Badge>;
    }

    Import from @cleverbrush/client/react:

    import { useSubscription } from '@cleverbrush/client/react';

    function LiveFeed() {
    const { events, state, send, close, error } = useSubscription(
    () => client.live.events(),
    { maxEvents: 100, enabled: true }
    );

    return (
    <div>
    <p>Status: {state}</p>
    {events.map((e, i) => <div key={i}>{JSON.stringify(e)}</div>)}
    </div>
    );
    }
    Return value Type Description
    lastEvent T | undefined Most recently received event
    events T[] Accumulated events (newest last)
    state string Connection state: connecting, connected, reconnecting, closed
    send (msg) => void Send a message (bidirectional subscriptions)
    close () => void Close the subscription
    error Error | undefined Last error, if any
    Option Type Default Description
    enabled boolean true Toggle the subscription on/off
    maxEvents number unlimited Maximum events to keep in the events array; 0 retains no history

    The /react subpath provides a createClient() that wraps the core client with TanStack Query hooks. Every endpoint becomes callable and provides hooks.

    Peer dependencies: react >=18, @tanstack/react-query ^5

    import { createClient } from '@cleverbrush/client/react';

    const client = createClient(api, { baseUrl: '/api' });

    // Each endpoint provides:
    client.todos.list(args?) // Direct fetch → Promise<T>
    client.todos.list.stream(args?) // NDJSON streaming → AsyncIterable<string>
    client.todos.list.useQuery(args?, options?) // TanStack useQuery
    client.todos.list.useSuspenseQuery(args?, options?) // TanStack useSuspenseQuery
    client.todos.list.useInfiniteQuery(argsFn, options) // TanStack useInfiniteQuery
    client.todos.list.useMutation(options?) // TanStack useMutation
    client.todos.list.queryKey(args?) // Query key array
    client.todos.list.prefetch(qc, args?) // Prefetch into cache

    // Group-level query key for bulk invalidation:
    client.todos.queryKey() // → ['@cleverbrush', 'todos']

    Keys follow a hierarchical structure for predictable invalidation:

    client.todos.queryKey()
    // → ['@cleverbrush', 'todos']

    client.todos.list.queryKey()
    // → ['@cleverbrush', 'todos', 'list']

    client.todos.get.queryKey({ params: { id: 42 } })
    // → ['@cleverbrush', 'todos', 'get', { params: { id: 42 } }]

    For manual key construction outside of the proxy:

    import { buildQueryKey, buildGroupQueryKey, QUERY_KEY_PREFIX } from '@cleverbrush/client/react';

    buildGroupQueryKey('todos'); // ['@cleverbrush', 'todos']
    buildQueryKey('todos', 'list'); // ['@cleverbrush', 'todos', 'list']
    buildQueryKey('todos', 'get', args); // ['@cleverbrush', 'todos', 'get', args]
    function TodoDetail({ id }: { id: number }) {
    const { data } = client.todos.get.useQuery({ params: { id } });
    return <h2>{data?.title}</h2>;
    }
    import { useQueryClient } from '@tanstack/react-query';

    function CreateTodo() {
    const queryClient = useQueryClient();
    const mutation = client.todos.create.useMutation({
    onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: client.todos.queryKey() });
    },
    });

    return (
    <button onClick={() => mutation.mutate({ body: { title: 'New todo' } })}>
    Add Todo
    </button>
    );
    }
    import { Suspense } from 'react';

    function TodoListSuspense() {
    const { data } = client.todos.list.useSuspenseQuery();
    return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
    }

    function App() {
    return (
    <Suspense fallback={<p>Loading…</p>}>
    <TodoListSuspense />
    </Suspense>
    );
    }
    const queryClient = useQueryClient();

    <button
    onMouseEnter={() => client.todos.get.prefetch(queryClient, { params: { id: 1 } })}
    >
    View Todo
    </button>
    function UserTodos({ userId }: { userId: number | null }) {
    const { data } = client.todos.list.useQuery({
    query: { userId: userId! },
    enabled: userId !== null,
    });
    // ...
    }
    function InfiniteTodos() {
    const { data, fetchNextPage, hasNextPage } = client.todos.list.useInfiniteQuery(
    (pageParam) => ({ query: { page: pageParam, limit: 10 } }),
    {
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) => allPages.length + 1,
    }
    );
    // ...
    }

    The useOptimisticMutation hook wraps TanStack Query's useMutation with automatic cache snapshot, optimistic update, and rollback on error. This replaces the manual onMutate/onError/onSettled pattern.

    import { useOptimisticMutation } from '@cleverbrush/client/react';

    function TodoItem({ todo }: { todo: Todo }) {
    const toggleMutation = useOptimisticMutation(client.todos.update, {
    queryKey: client.todos.list.queryKey(),
    optimisticUpdate: (oldTodos, variables) =>
    (oldTodos ?? []).map(t =>
    t.id === variables.params.id
    ? { ...t, completed: variables.body.completed }
    : t
    ),
    onSettled: () => {
    queryClient.invalidateQueries({
    queryKey: client.todos.queryKey()
    });
    }
    });

    return (
    <button onClick={() => toggleMutation.mutate({
    params: { id: todo.id },
    body: { completed: !todo.completed }
    })}>
    {todo.completed ? '✓' : '○'}
    </button>
    );
    }

    The hook handles:

    1. Cancel — cancels in-flight queries for the given queryKey
    2. Snapshot — captures the current cache state
    3. Optimistic update — applies your optimisticUpdate function
    4. Rollback — restores the snapshot if the mutation fails
    5. Invalidate — invalidates the cache when the mutation settles
    import { isApiError, isTimeoutError } from '@cleverbrush/client';

    const { error } = client.todos.list.useQuery();

    if (isApiError(error)) {
    console.log(error.status, error.body);
    } else if (isTimeoutError(error)) {
    console.log('Timed out after', err.timeout, 'ms');
    }
    Type Description
    EndpointCall Callable endpoint function type
    EndpointCallArgs<E> Request argument shape for an endpoint
    EndpointResponse<E> Success response type for an endpoint
    TypedClient<T> Full client type mirroring the contract
    ClientOptions Configuration for createClient()
    Middleware Middleware function type
    PerCallOverrides Per-call override options (retry, timeout, etc.)
    UnifiedClient<T> React client type with hooks on every endpoint
    1. Compile time: TypeScript infers the full type of each endpoint from the defineApi() contract. TypedClient<T> maps each group/endpoint to a typed async function.

    2. Runtime: createClient() returns a two-level Proxy. When you call client.todos.list(args), the proxy:

      • Calls .introspect() on the endpoint builder to get HTTP method, base path, and path template
      • Serializes path parameters via ParseStringSchemaBuilder.serialize()
      • Serializes query parameters to a URL query string
      • JSON-encodes the body for POST/PUT/PATCH requests
      • Sends the request via fetch with auth headers
    3. React layer (optional): The /react createClient() wraps the core client in an additional proxy layer that attaches TanStack Query hook factories to each endpoint function.

    Import Description
    @cleverbrush/client Core client, errors, middleware utilities, types
    @cleverbrush/client/retry Retry middleware with exponential backoff
    @cleverbrush/client/timeout AbortController-based timeout middleware
    @cleverbrush/client/dedupe Request deduplication middleware
    @cleverbrush/client/idempotency Idempotency key middleware (deduplicates mutations)
    @cleverbrush/client/cache Throttling cache + tag-based cache invalidation middleware
    @cleverbrush/client/batching Request batching middleware
    @cleverbrush/client/optimistic-update Optimistic update middleware (mutation tracking)
    @cleverbrush/client/offline-queue Offline queue middleware (queue + replay)
    @cleverbrush/client/react TanStack Query hooks + unified client
    import { optimisticUpdate } from '@cleverbrush/client/optimistic-update';
    

    Tags mutation requests (POST/PUT/PATCH/DELETE) with a unique ID and tracks network failures in an inspectable store. Designed to work with the useOptimisticMutation React hook.

    import { createClient } from '@cleverbrush/client';
    import { optimisticUpdate } from '@cleverbrush/client/optimistic-update';

    const client = createClient(api, {
    middlewares: [optimisticUpdate()]
    });

    Pass a shared store to inspect failed mutations:

    import { optimisticUpdate, type OptimisticUpdateStore } from '@cleverbrush/client/optimistic-update';

    const store: OptimisticUpdateStore = { failures: [] };

    const client = createClient(api, {
    middlewares: [optimisticUpdate({ store })]
    });

    // After a network error:
    console.log(store.failures);
    // → [{ id, url, init, error, timestamp }]
    Option Type Default Description
    store OptimisticUpdateStore { failures: [] } Shared mutable store
    skip (url, init) => boolean — Skip tagging for specific requests
    await client.todos.create({
    body: { title: 'test' },
    optimisticUpdate: { skip: true } // skip tagging for this call
    });
    import { offlineQueue } from '@cleverbrush/client/offline-queue';
    

    Queues mutation requests (POST/PUT/PATCH/DELETE) when the browser reports offline (navigator.onLine). Automatically replays queued mutations when connectivity is restored.

    offlineQueue() must be the outermost middleware (first in the array) so that retry/timeout/etc. middlewares re-apply when queued mutations are replayed:

    middlewares: [
    offlineQueue(), // outermost
    retry({ limit: 3 }), // re-applies on replay
    timeout({ timeout: 10000 }),
    batching(),
    ]
    import { createClient } from '@cleverbrush/client';
    import { offlineQueue } from '@cleverbrush/client/offline-queue';

    const client = createClient(api, {
    middlewares: [offlineQueue()]
    });

    Pass a shared store to observe queue state:

    import { offlineQueue, type OfflineQueueStore } from '@cleverbrush/client/offline-queue';

    const store: OfflineQueueStore = { queue: [], isOnline: true, isReplaying: false };

    const client = createClient(api, {
    middlewares: [offlineQueue({ store })]
    });

    // Check queue status:
    console.log(store.isOnline); // boolean
    console.log(store.queue.length); // queued mutations
    console.log(store.isReplaying); // currently replaying
    Option Type Default Description
    store OfflineQueueStore { queue: [], isOnline: true, isReplaying: false } Shared mutable store
    skip (url, init) => boolean — Skip queue for specific requests
    maxRetries number 3 Max flush retries per queued item
    await client.todos.create({
    body: { title: 'test' },
    offlineQueue: { skip: true } // bypass queue for this call
    });

    BSD 3-Clause

    batching
    cache
    dedupe
    idempotency
    offline-queue
    optimistic-update
    react
    retry
    timeout