API Client
createApiClient()
createApiClient is the low-level factory. In a normal app, LimestoneProvider wires it automatically from your defineConfig. Call createApiClient directly only in scripts or custom setups.
import { createApiClient, createStore } from '@objectifthunes/limestone-sdk';
import { createExpoSecureStore } from '@objectifthunes/limestone-sdk/expo-secure-store';
const store = createStore({ auth: { status: 'idle', user: null, tokens: null }, network: { connected: true, type: 'wifi' }, mutations: [] });
const storage = createExpoSecureStore();
const client = createApiClient({
baseUrl: 'https://api.example.com',
store,
storage,
auth: {
refreshEndpoint: '/auth/refresh',
onUnauthorized: () => router.push('/login'),
},
retry: {
maxAttempts: 3,
backoff: 'exponential',
backoffDelayMs: 500,
},
offline: { queueMutations: true },
headers: { 'X-App-Version': '2.0.0' },
timeout: 10_000,
fetchFn: globalThis.fetch,
});
Config reference
| Field | Type | Required | Description |
|---|---|---|---|
baseUrl | string | Yes | Prepended to every path |
store | Store<AppState> | Yes | Source of auth tokens and network state |
storage | SecureStorageProvider | No | Required for offline mutation queue |
auth.refreshEndpoint | string | No | Path to call for token refresh |
auth.onUnauthorized | () => void | No | Called when refresh fails or is not configured |
retry.maxAttempts | number | No | Total attempts including first. Default 1 (no retry) |
retry.backoff | 'exponential' | 'linear' | 'none' | No | Delay strategy between attempts |
retry.backoffDelayMs | number | No | Base delay in ms |
offline.queueMutations | boolean | No | Queue POST/PUT/PATCH/DELETE when offline |
headers | Record<string, string> | No | Default headers on every request |
timeout | number | No | Abort after N milliseconds |
fetchFn | typeof globalThis.fetch | No | Injectable for tests |
Typed methods
All methods are generic. The resolved type is the response body.
// GET
const user = await client.get<User>('/users/me');
// POST
const session = await client.post<Session>('/sessions', {
body: { email, password },
skipAuth: true, // omit Bearer header for this request
});
// PUT
await client.put<User>('/users/me', { body: { name: 'Alice' } });
// PATCH
await client.patch<Post>('/posts/1', { body: { title: 'Updated' } });
// DELETE
await client.delete('/posts/1');
RequestOptions
Pass as the second argument to any method.
| Field | Type | Description |
|---|---|---|
body | unknown | Serialized as JSON |
headers | Record<string, string> | Merged with default headers |
skipAuth | boolean | Omit the Authorization header |
signal | AbortSignal | For manual cancellation |
Auth lifecycle
When auth.refreshEndpoint is configured:
- Every request automatically receives
Authorization: Bearer <accessToken>fromstore.auth.tokens. - If the server returns
401, the client callsrefreshEndpointwith the current refresh token. - The access token in the store is updated.
- The original request is replayed once with the new token.
- If refresh fails,
onUnauthorizedis called and the error is thrown.
// Skip auth header for login and registration endpoints
await client.post('/sessions', { body: { email, password }, skipAuth: true });
await client.post('/users', { body: { email, password }, skipAuth: true });
GET request deduplication
Concurrent identical GET requests share one in-flight promise. The second and subsequent callers wait for the first and receive the same response.
// These three calls fire simultaneously — only ONE fetch goes to the network
const [a, b, c] = await Promise.all([
client.get<User>('/users/me'),
client.get<User>('/users/me'),
client.get<User>('/users/me'),
]);
// a === b === c (same resolved object)
Deduplication is keyed on the path plus any per-request headers. A different headers value produces a separate in-flight entry.
Offline mutation queue
When offline.queueMutations: true and storage is provided, any POST, PUT, PATCH, or DELETE made while the device is offline is serialized to SecureStorageProvider instead of being dropped.
// Network is down — this enqueues rather than throwing
await client.post('/events', { body: { name: 'page_view' } });
// Returns undefined immediately; resolves when flushed
The queue is flushed automatically when the network reconnects. Each queued mutation is a QueuedMutation:
interface QueuedMutation {
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
body?: Record<string, unknown>;
headers?: Record<string, string>;
}
Retry with backoff
Configure retry to retry failed requests (network errors or 5xx responses).
const client = createApiClient({
baseUrl: 'https://api.example.com',
store,
retry: {
maxAttempts: 3, // 1 initial + 2 retries
backoff: 'exponential',
backoffDelayMs: 500, // 500ms, 1000ms, 2000ms
},
});
| Strategy | Delay formula |
|---|---|
exponential | backoffDelayMs * 2^(attempt - 1) |
linear | backoffDelayMs * attempt |
none | 0ms — retries fire immediately |
useApi() hook
useApi returns the ApiClient wired by LimestoneProvider. It throws if api was not included in the config.
import { useApi } from '@objectifthunes/limestone-sdk';
function ProfileScreen() {
const api = useApi();
const [user, setUser] = React.useState<User | null>(null);
React.useEffect(() => {
api.get<User>('/users/me').then(setUser);
}, [api]);
return <Text>{user?.name ?? 'Loading...'}</Text>;
}
Error handling
A non-2xx response throws an AppError-compatible error with a status property. Use isAppError to narrow in catch blocks.
import { isAppError } from '@objectifthunes/limestone-sdk';
try {
const user = await client.get<User>('/users/me');
} catch (e) {
if (isAppError(e)) {
console.error(e.code, e.message);
}
}