Data Fetching
aihu provides several primitives for fetching data, ranging from reactive resource signals declared in @state, to a standalone resource utility for use outside SFCs, to server-side loaders for SSR routes.
resource() in @state
resource() is one of the @state wrapper intrinsics (alongside state(), prop(), derived(), action()) — it binds an async fetcher to a reactive signal. Bare (no metadata) or wrapped (config object first):
@state {
let userId = prop<number>({ default: 1 })
// bare — just the fetcher thunk
const user = resource(() => fetchUser(userId))
// wrapped — add describe/expose to surface to agents
const recentPosts = resource(
{ describe: 'Posts by the current user', expose: 'read' },
() => fetchPosts(userId),
)
}
@template {
<suspense fallback="Spinner">
<div>{user.data?.name}</div>
</suspense>
}The resource variable is a 4-field loader object:
resource.loading—truewhile the fetch is in-flight.resource.data— the resolved value (ornullif loading/error).resource.error— the error (ornullif loading/success).resource.refetch()— re-runs the fetcher on demand.
When any signal read inside the fetcher changes (e.g. userId), the resource re-fetches automatically.
The loader-state pattern
All resource() data in aihu follows the same 4-field shape:
| State | loading |
data |
error |
|---|---|---|---|
| Loading | true |
null |
null |
| Success | false |
data | null |
| Error | false |
null |
Error |
<suspense fallback="..."> wraps a resource consumer to declare a loading fallback in the template:
<suspense fallback="Spinner">
<div>{user.data?.name}</div>
</suspense>Current limitation.
<suspense>parses and validates today, but its runtime boundary is presently a pass-through stub — it always renders the wrapped content rather than gating on the resource'sloadingstate. Branch onresource.loadingdirectly (if={user.loading}/else) if you need a working loading state today; treat<suspense>as declaring intent ahead of the boundary becoming functional.
createResource from @aihu-plugin/data
Use createResource directly in TypeScript outside of SFCs. Unlike the @state resource() intrinsic, this is a lower-level, standalone API with its own return shape:
import { createResource } from '@aihu-plugin/data'
import { signal } from '@aihu/signals'
const userId = signal<string | null>('1')
const user = createResource(
userId, // key — a Signal; changes trigger refetch
(id) => fetch(`/api/users/${id}`).then(r => r.json()), // fetcher — receives the resolved key
)
// user.state is a Signal<DataState<T>>, a status-discriminated union:
// { status: 'idle' | 'loading' | 'ready' | 'error' | 'streaming', ... }
user.refetch()
user.invalidate()The key argument is a signal (not a thunk) holding the current key value; the resource automatically re-fetches when it changes. user.state() reads the current DataState; check .status to discriminate loading/ready/error/streaming.
Resource store and SSR dehydration
For SSR, use a resource store to cache and dehydrate resources: createResourceStore, createResourceSerializer, and the ResourceStoreToken injection token are all exported from @aihu-plugin/data alongside createResource.
Register the data plugin in aihu.config.ts:
import { defineAihuConfig } from '@aihu/server'
import { data } from '@aihu-plugin/data'
export default defineAihuConfig({
plugins: [data()],
})aihu.config.ts/defineAihuConfig is the legacy fallback for general app/build config (see the Deployment guide — the primary app config surface is now the inline viteAihuPlugin({...}) in vite.config.ts), but it remains the live, current registration path specifically for @aihu/plugin-shaped compiler plugins like this one. viteAihuPlugin({...})'s own plugins field is a different thing — it takes Vite plugins, not @aihu/plugin plugins — so data() belongs in defineAihuConfig, not there. See the Authoring Plugins guide for the full registration model.
Server loaders
Server loaders run on the server and provide data to SSR-rendered pages. Define a loader with defineLoader from @aihu/server:
import { defineLoader } from '@aihu/server'
export const loader = defineLoader(async (ctx) => {
const users = await db.users.findMany()
return { users }
})The loader receives a LoaderContext with the request, params, and URL. What happens to the return value next depends on whether the route is governed.
Server loaders → SFC handoff
There are two distinct paths, and only one of them delivers a route.data prop.
Governed routes — route.data prop
A route becomes governed by declaring a data: field in its @route block (data: { type: 'TypeName', preview: [...] }). This opts the route into aihu's entitlement-gated data system: the loader's result is evaluated against the request's principal, and the component receives the resolved value as the data field on its route prop — a fully entitled payload for an authorized caller, or a withheld/preview-only shape otherwise.
src/pages/posts/[slug].loader.ts:
import { defineLoader } from '@aihu/server'
export const loader = defineLoader(async (ctx) => {
return await db.posts.findOne({ slug: ctx.params.slug })
})src/pages/posts/[slug].aihu:
@route {
path: "/posts/[slug]"
ssr: true
data: { type: "Post", preview: ["title"] }
}
@state {
let route = prop<{ params: { slug: string }; data: { title: string; body: string } }>()
}
@template {
<article>
<h1>{route.data.title}</h1>
<p>{route.data.body}</p>
</article>
}The full entitlement-gating semantics (what a non-authorized caller's route.data looks like, how preview fields are chosen) are beyond this guide's scope — this is the pattern to reach for when a page's data needs to vary by who's asking (paywalled/tiered content, per-user views).
Plain routes — no props, inline JSON only
A route with no data: declaration still runs its defineLoader, but the SFC itself receives no props from it. The result is embedded in the response as an inline script tag:
<script type="application/json" id="__aihu_loader__">{"users":[...]}</script>There is currently no built-in client-side reader for this tag — if the component needs the value, read and JSON.parse it yourself (e.g. in an onMount()). Use a plain loader when you only need the data to influence the HTML aihu itself renders (e.g. via a top-level render function), not when the SFC needs it as a prop.
On-demand client→server calls. aihu previously shipped a
$servermacro and acreateServerCallclient stub for calling server functions from the client over RPC. Both were retired — the feature never fully wired up (see the Macro Vocabulary spec §2.12) — with no drop-in replacement. For data that depends on post-load UI state, fetch from a server route (defineApiRoute/defineStreamRoutein@aihu/server) with a plainfetch, or drive it through a governed loader.
Server response helpers
From @aihu/server:
| Helper | Description |
|---|---|
json(data, status?) |
Return a JSON response |
notFound() |
Return a 404 response (no message argument) |
badRequest(message?) |
Return a 400 response |
serverError(err?) |
Return a 500 response (message suppressed unless DEV) |
methodNotAllowed(allowed) |
Return a 405 response for the given array of allowed HttpMethods |
defineApiRoute(method, pattern, handler) |
Declare a method+pattern-matched API route |
Streaming routes
For streaming HTTP responses (e.g. server-sent events), use defineStreamRoute from @aihu/server. The handler returns a ReadableStream<string> — there's no ctx/writer callback form:
import { defineStreamRoute } from '@aihu/server'
export const events = defineStreamRoute(async (req) => {
return new ReadableStream({
start(controller) {
controller.enqueue('event: connected\ndata: {}\n\n')
// ... enqueue further frames, then:
controller.close()
},
})
})defineStreamRoute sets streaming-appropriate response headers automatically.