Networking & Data
HTTP client, file storage, per-space KV. For typed models with relations and queries, use @construct-space/graph.
useHttp()
Full HTTP client — typed verbs, JSON in/out, query params, retry, interceptors, scoped instances. In Tauri the requests route through the host's Rust http_fetch command (reqwest) so CORS is bypassed; on web it falls back to native fetch.
Use this — do not bring another HTTP library
useHttp is the SDK's HTTP surface. Don't bundle axios, ky, ofetch, dio, etc. — they duplicate the surface, bypass the host's request pipeline (interceptors, auth, retry), and waste bundle space. The shape below covers everything those libraries do.
function useHttp(): {
// Verb shortcuts — return parsed body as T; throw on non-2xx
get<T>(url, config?): Promise<T>
delete<T>(url, config?): Promise<T>
head<T>(url, config?): Promise<T>
options<T>(url, config?): Promise<T>
post<T>(url, data?, config?): Promise<T>
put<T>(url, data?, config?): Promise<T>
patch<T>(url, data?, config?): Promise<T>
// Full request — returns HttpResponse<T> { data, status, headers, url }
request<T>(config: HttpRequestConfig): Promise<HttpResponse<T>>
// Scoped instance — own baseUrl, headers, interceptors
create(defaults?: HttpClientDefaults): HttpClient
defaults: HttpClientDefaults
interceptors: HttpInterceptors
isHttpError(err): err is HttpError
}The 90% case
const http = useHttp()
// GET → typed JSON body
const me = await http.get<User>('/api/me')
// POST JSON
const note = await http.post<Note>('/api/notes', { title: 'Hello' })
// Query params (arrays repeat the key, null/undefined are skipped)
const results = await http.get('/api/search', {
params: { q: 'foo', tags: ['ts', 'vue'], limit: 20 },
})Scoped client for a third-party API
http.create() returns a child client with its own defaults. Use one per external service so headers and baseUrl are set once.
const stripe = http.create({
baseUrl: 'https://api.stripe.com/v1',
headers: { Authorization: `Bearer ${secret}` },
timeoutMs: 30_000,
})
const charges = await stripe.get('/charges', { params: { limit: 10 } })Interceptors — auth refresh, logging, request mutation
http.interceptors.request.use((config) => {
config.headers = { ...config.headers, 'X-Trace-Id': crypto.randomUUID() }
return config
})
http.interceptors.response.use(
(res) => res,
async (err) => {
if (err.status === 401) {
await refreshToken()
return http.request(err.config!) // retry once
}
throw err
},
)Retry — idempotent methods, 5xx and network errors
await http.get('/api/flaky', { retries: 3 })
// up to 4 attempts; exponential backoff capped host-sideCustom retry predicate:
await http.request({
method: 'POST',
url: '/api/idempotent-write',
data: payload,
retries: 2,
shouldRetry: (err) => err.status === 503,
})Cancellation
const controller = new AbortController()
const promise = http.get('/api/slow', { signal: controller.signal })
setTimeout(() => controller.abort(), 1000)
try {
await promise
} catch (err) {
if (http.isHttpError(err) && err.cancelled) {
// request was cancelled
}
}Binary downloads
const bytes = await http.get<Uint8Array>('/api/files/123', { responseType: 'bytes' })
const blob = await http.get<Blob>('/api/files/123', { responseType: 'blob' })FormData uploads
const form = new FormData()
form.append('file', fileInput.files![0])
form.append('caption', 'My photo')
await http.post('/api/upload', form) // Content-Type set automaticallyError handling
try {
await http.post('/api/notes', payload)
} catch (err) {
if (http.isHttpError(err)) {
if (err.isNetworkError) showOffline()
else if (err.status === 422) showValidation(err.data)
else throw err
}
}useStorage()
File storage. Your space's root is {space_id}/<path> — pick whatever path you want inside it. Two spaces can't read each other's files.
function useStorage(): {
upload(file: Blob | File, opts?: UploadOptions): Promise<UploadResult>
presign(opts?: PresignOptions): Promise<PresignResult>
signedUrl(opts: SignedUrlOptions): Promise<string>
download(path: string): Promise<Blob>
delete(path: string): Promise<void>
list(opts?: ListOptions): Promise<ListResult>
copy(opts: CopyOptions): Promise<void>
exists(path: string): Promise<boolean>
loading: Ref<boolean>
error: Ref<string | null>
}Upload a file
const storage = useStorage()
// path within your space — root is {space_id}/
const { url, path } = await storage.upload(file, { path: 'reports/q1.pdf' })
// or let it auto-derive from the file name
await storage.upload(file)List + read
const { items } = await storage.list({ prefix: 'reports/' })
const blob = await storage.download('reports/q1.pdf')Signed URL for sharing
const link = await storage.signedUrl({ path: 'reports/q1.pdf', ttlSeconds: 3600 })Cross-space sharing is host-mediated
There's no cross-space path. If a space needs to surface a file to another space (e.g. Drive sharing a doc with Notes), do it through a Graph relation + useAccess<T>() grant — not by writing into another space's prefix.
useLocalStorage()
Per-space, per-profile persistent KV. IndexedDB-backed, namespaced by space id so two spaces can't collide on the same key. Survives reloads.
Use this for view prefs, last-opened-tab, pinned-item ids — UI state the model doesn't need. For shared/typed data, switch to @construct-space/graph.
function useLocalStorage(): {
get<T = unknown>(key: string): Promise<T | null>
set<T = unknown>(key: string, value: T): Promise<void>
has(key: string): Promise<boolean>
del(key: string): Promise<void>
keys(): Promise<string[]>
clear(): Promise<void>
}const local = useLocalStorage()
await local.set('lastFolderId', folder.id)
const last = await local.get<string>('lastFolderId')Storage is scoped per Space + per profile. Two profiles installing the same Space see distinct stores. Spaces can't read each other's keys.