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(),
]);- The first queued request starts a
windowMstimer. - Additional requests arriving before the timer fires join the same batch.
- When the timer fires (or
maxSizeis reached), all queued requests are sent as a singlePOST /__batch. - The server processes each sub-request through its full pipeline and returns an array of sub-responses.
- 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 batchSubscription 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:
- Cancel — cancels in-flight queries for the given
queryKey - Snapshot — captures the current cache state
- Optimistic update — applies your
optimisticUpdatefunction - Rollback — restores the snapshot if the mutation fails
- 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 |
-
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. -
Runtime:
createClient()returns a two-levelProxy. When you callclient.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
fetchwith auth headers
- Calls
-
React layer (optional): The
/reactcreateClient()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