Server Caller#
Loaders and actions can call each other server-side without an HTTP round-trip using ctx.call. The called procedure runs in the same request scope, its input is validated, and the result carries the authored type with no JSON serialization. For testing outside a live request, createCaller(c) binds the same mechanism to any Hono Context you provide.
Calling from inside a handler#
Every loader and action receives a call method on its context object. Pass another loader ref as the first argument to invoke it directly:
// src/pages/movie.server.ts
import { serverRoute } from 'hono-preact';
import { serverLoaders as reviewLoaders } from './reviews.server.js';
const reviewsLoader = reviewLoaders.default;
const route = serverRoute('/movies/:id');
export const serverLoaders = {
default: route.loader(async (ctx) => {
const movie = await fetchMovie(ctx.location.pathParams.id);
const reviewsResult = await ctx.call(reviewsLoader, {
location: { pathParams: ctx.location.pathParams },
});
if (!reviewsResult.ok) throw reviewsResult.outcome;
return { movie, reviews: reviewsResult.value };
}),
};
ctx.call returns a CallResult<T> discriminated union. Narrow on ok before reading value. A deny or redirect from the inner procedure surfaces as { ok: false, outcome } rather than throwing into the outer handler, so the caller decides whether to propagate or handle it:
const result = await ctx.call(anotherLoader, { location });
if (result.ok) {
// result.value: T (the authored type, not Serialize<T>)
return { combined: result.value };
} else {
// propagate any deny or redirect from the inner loader
throw result.outcome;
}
Actions also receive ctx.call. Use it to delegate to a shared action from within another action:
// src/pages/orders.server.ts
import { defineAction } from 'hono-preact';
import { serverActions as notifyActions } from './notifications.server.js';
export const serverActions = {
createOrder: defineAction(async (ctx, payload: { items: string[] }) => {
const order = await db.orders.create(payload);
const notifyResult = await ctx.call(notifyActions.sendConfirmation, {
orderId: order.id,
});
if (!notifyResult.ok) throw notifyResult.outcome;
return { orderId: order.id };
}),
};
Testing with createCaller(c)#
Outside a running request (in unit or integration tests), use createCaller to build a caller bound to a Hono Context:
import type { Context } from 'hono';
import { createCaller } from 'hono-preact';
import { serverLoaders } from './movie.server.js';
it('returns the movie', async () => {
// The framework does not mint a synthetic Context; capture a real one from a
// one-off middleware wrapped around a test request.
let c!: Context;
app.use('*', async (ctx, next) => {
c = ctx;
await next();
});
await app.request(new Request('https://app.test/movies/42'));
const caller = createCaller(c);
const result = await caller.call(serverLoaders.default, {
location: { pathParams: { id: '42' } },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.movie.id).toBe(42);
}
});
createCaller(c) reuses an already-open request scope if one exists, and otherwise opens a fresh scope bound to c. In a test there is normally no active scope, so it runs standalone; inside a live handler ctx.call goes through the same mechanism and reuses the request's active scope automatically.
Streaming loaders and actions#
Streaming refs are first-class through the same call surface. Calling a streaming loader runs its middleware and schema coercion up front, then resolves to the loader's async generator; iterate it to run the loader body. Pass signal to end the stream from the caller's side:
import { createCaller } from 'hono-preact';
import { serverLoaders } from './projects-shell.server.js';
it('streams activity events', async () => {
const ctrl = new AbortController();
const r = await createCaller(c).call(serverLoaders.activity, {
signal: ctrl.signal,
});
expect(r.ok).toBe(true);
if (r.ok) {
const events = [];
for await (const e of r.value) {
events.push(e);
if (events.length === 3) break;
}
ctrl.abort();
}
});
A middleware deny or redirect still surfaces as { ok: false, outcome } before the generator is produced. An error thrown mid-stream propagates from the generator itself (a rejected next()), matching the HTTP path where the SSE stream terminates.
A streaming action resolves to AsyncGenerator<TChunk, TResult>: iterate for chunks; the final next()'s value (when done is true) is the action's return value.
How it works#
- Runs the procedure's own
usemiddleware. The called procedure's per-unitusearray is applied: auth guards, rate limits, and any observers declared ondefineLoaderordefineAction. The app-level chain and the page (route) middleware are not run for the call: the app chain already fired for the outer request, and the callee's own route-level middleware is bypassed entirely. So a guard placed only on a route node does not protect a procedure reached throughctx.callfrom a different route. Put any guard that must protect a procedure regardless of how it is entered on that procedure's owndefineLoader/defineAction({ use }), not only on its route. - Reuses the live request scope. Inside a live handler,
ctx.callinherits the outer request's async-local storage context, so the HonoContext, session state, and middleware side-effects are shared.createCaller(c)used outside a handler opens a fresh scope per call. - Returns the authored type, not the wire shape. No JSON round-trip occurs. The return type of
ctx.call(loader)isT(the loader's declared return), notSerialize<T>. ADatefield stays aDate. - Does not share the loader result cache. The loader result cache is populated on the SSR page-render path.
ctx.callinvokes the loader function directly, so the result is always freshly computed. Two calls to the same loader produce two executions. - Validates inputs. If the called loader has a
searchSchemaorparamsSchema, or the called action has aninputschema, the caller validates the supplied location or payload before invoking the function. A validation failure surfaces as{ ok: false, outcome }, not a raw throw. - Unexpected errors reject the promise.
ctx.callresolves aredirect,deny, or validation failure to{ ok: false, outcome }, but an unexpected thrown error (a bug or exception inside the callee) propagates as a rejected promise. Wrap the call intry/catchif you need to contain those. - Does not apply the callee's
timeoutMs. A loader's or action'stimeoutMsis a deadline the HTTP handler enforces at the request entrypoint;ctx.callruns the procedure in-process under the outer request'ssignal, so the callee's owntimeoutMsis not applied here. If an in-process call needs its own deadline, race the returned promise against one yourself.
Limitations#
- No synthetic context minting.
createCaller(c)accepts aContextyou supply. Obtain one fromapp.request(new Request(...))or your test helper. The framework does not mint a synthetic context.
API reference#
ctx.call(loader, opts?)#
| Argument | Type | Description |
|---|---|---|
loader | LoaderRef<T, false> | LoaderRef<T, true> | The loader to call. A single-value loader resolves to its value; a streaming loader resolves to its async generator. |
opts.location | CallLoaderLocation (optional) | Path, path params, and search params for the loader's location. Defaults to the current request path with empty params. |
opts.signal | AbortSignal (optional, streaming loaders) | Composed with the request's signal and threaded to the loader as ctx.signal; aborting it ends the stream. |
Returns Promise<CallResult<T>> for a single-value loader, Promise<CallResult<AsyncGenerator<T, void, unknown>>> for a streaming loader.
ctx.call(action, payload)#
| Argument | Type | Description |
|---|---|---|
action | ActionRef<TPayload, TResult, TChunk> | The action to call. A single-value action resolves to its result; a streaming action resolves to its async generator. |
payload | TPayload | The action payload. Validated against the action's input schema when one is present. |
Returns Promise<CallResult<TResult>> for a single-value action, Promise<CallResult<AsyncGenerator<TChunk, TResult, unknown>>> for a streaming action (an async generator handler with a chunk type).
createCaller(c)#
import { createCaller } from 'hono-preact';
const caller = createCaller(c: Context): ServerCaller;
Returns a ServerCaller with a call method that accepts the same arguments as ctx.call. Each call reuses the active request scope if one is already open, otherwise it opens a fresh scope bound to c. Use in tests and any context outside a live request handler.
CallResult<T>#
type CallResult<T> = { ok: true; value: T } | { ok: false; outcome: Outcome };
Narrow on ok to access value or inspect outcome. The outcome type is Outcome (one of redirect | deny | timeout | render). Re-throw it to propagate to the outer handler, or inspect outcome.__outcome to handle specific cases.
CallLoaderLocation#
type CallLoaderLocation = {
path?: string;
pathParams?: Record<string, string>;
searchParams?: Record<string, string>;
};
Optional location shape for ctx.call(loader, { location }). All fields are optional; omitted fields default to the current request path and empty params.
See also#
- Server Loaders: defining loaders and composing multiple loaders per route.
- Server Actions: defining and calling actions.
- Middleware: the per-unit and app-level middleware model.