Authoring server functions
A server function is created with serverFn. Its first parameter is always the
request context (rq by convention) — the framework passes it, callers
never do. Everything after it is your own typed arguments.
import { serverFn } from '@sigx/server';
export const greet = serverFn(async (rq, name: string) => {
return `Hello, ${name}!`;
});
// client stub: greet(name: string): Promise<string>
Where server functions live
There are two placement conventions. Both produce the identical runtime API — the difference is only where the code sits and how the transform treats it.
*.server.ts modules
Put a server function in a file whose name ends in .server.ts (or
.server.tsx) and the whole module is server-only. It may import database
clients, secrets and node: builtins freely. The client build swaps the module
for typed fetch stubs, so none of that reaches the browser; the server import is
the real module. You import from the same path on both sides:
// src/todos.server.ts
import { serverFn } from '@sigx/server';
import { db } from './db';
export const listTodos = serverFn(async (rq) => db.todos.all());
Inline, co-located with a component
A module-scope const x = serverFn(...) may also sit directly in a
component file; the build lifts it out to the server. Co-location keeps a small
read next to the component that uses it.
import { component, useData } from 'sigx';
import { serverFn } from '@sigx/server';
// lifted to the server; may reference its args, imports and server globals
const serverTime = serverFn((rq) => new Date().toISOString());
export const Clock = component(() => {
const now = useData(() => ['server-time'], serverTime);
return () => now.match({ ready: (t) => <p>Server time: {t}</p> });
});
Because an inline function is lifted away from its surroundings, its body may
reference only its own parameters and locals, imports, and globals.
Capturing component scope, signals, props, module-scope locals, JSX, or
type-only imports is a build error, not a silent bug — the transform tells
you at compile time. When a lifted function needs shared server code, import it
(from a *.server.ts module); don't close over it.
There is no closure serialization anywhere. Data crosses the boundary only as the typed arguments you pass.
Two authoring forms
serverFn accepts either an implementation directly, or an options object.
Direct form
Pass the implementation. It takes the context plus any number of typed arguments — zero ceremony, ideal for internal calls.
export const rename = serverFn(async (rq, id: string, title: string) => {
return db.todos.rename(id, title);
});
The parameter types are compile-time only: the wire arguments are not
schema-validated and the argument count is not enforced at runtime. In
development, a direct-form function that receives wire input logs a one-time
warning — reported as the function receiving input it does not declare — a nudge to
move validation into the options form's input schema once a function is a real
client boundary. Use the direct form for trusted, app-internal
calls; reach for the options form (below) when a function is a validated boundary.
Options form
Pass { input, authorize, handler, … } for a validated, authorized boundary.
It takes exactly one input, described by a
Standard Schema (Zod, Valibot, ArkType, …) that
runs on every transport.
import { serverFn, principal, requireAuthenticated } from '@sigx/server';
import * as z from 'zod';
const QuoteInput = z.object({ symbol: z.string(), qty: z.number().int() });
export const quote = serverFn({
input: QuoteInput, // validated server-side on every call → 400 on failure
authorize: requireAuthenticated, // runs AFTER input validation, on every transport
async handler(rq, input) {
return priceQuote(principal(rq), input); // input is typed from the schema
},
});
The input type S is inferred from input; if you omit input, it is inferred
from the handler's parameter annotation. With neither, S is void and the
callable takes no arguments — you call it as fn(), not fn(undefined).
ServerFnOptions fields:
| Field | Purpose |
|---|---|
input? | A Standard Schema. Always validated server-side; a rejection becomes ServerFnError(400, 'Invalid input', { issues }). Also the source of the input type. |
authorize? | A ServerPolicy or array of them — (principal, rq, op) => boolean — run after input validation on every transport (in-process SSR calls included). Strict-true to allow; anything else is a 403, or a 401 when the principal is null. |
handler | The implementation: (rq, input) => R | Promise<R>. |
invalidates? | (input, result) => InvalidatePattern[] — keys to invalidate after the handler runs. Declare it after handler so TypeScript infers result. Patterns are key strings, tuple prefixes, or server-function references, and reach every matching read whether or not it carries a cache option. |
cache? | Marks a side-effect-free GET read ({ maxAge, staleWhileRevalidate?, public?, sMaxAge? }) and emits Cache-Control. Mutually exclusive with invalidates. |
form? | true marks a progressive-enhancement form target (accepts form-encoded bodies, does a 303 redirect). Must be the literal true, and requires input — a form target without an input schema is a definition-time error. Mutually exclusive with cache. The native action is stamped by the resume extractor, so the no-JS half needs a resume component; elsewhere the form still posts as RPC. |
id? | An explicit, stable string id that pins the route across file moves/renames. |
allowAnonymous? | The literal true, waiving the identity gate for a deliberately public endpoint. Middleware and authentication still run; declared policies still run, with a nullable principal. |
The three stages
Every server function is a public endpoint reachable on every transport, and the work of protecting one splits into three concerns with three different natural scopes. Each has its own vocabulary:
| Stage | Where it lives | What it is for |
|---|---|---|
| Middleware | app-wide, createServerApp({ middleware }) | cross-cutting work that always runs — logging, tracing, rate limiting, response headers. Never per-function disableable. |
| Authentication | app-wide, createServerApp({ authenticate }) | deciding who the caller is, once. Produces the Principal. |
| Authorization | per function, authorize: (with an app-wide default) | deciding whether this caller may run this operation. |
They run in that order, and the identity gate closes before the wire payload is revived:
middleware → authenticate → identity gate → reviveWire → input validation → authorize → handler
That ordering is load-bearing: an anonymous attacker's payload never reaches the
codec or the validator, and by the time a policy runs, op.input is the parsed,
trusted resource rather than raw wire data.
The app
createServerApp is the one value that holds everything app-wide — the pipeline,
the endpoint posture, and the principal codec:
// src/server-app.ts
import { createServerApp, requireAuthenticated } from '@sigx/server';
export const app = createServerApp({
middleware: [rateLimit, requestLog],
authenticate: (rq) => sessionFrom(rq.request),
authorize: requireAuthenticated, // the default every fn inherits
origin: 'verify-when-present', // posture, stated once
maxBodyBytes: 1_000_000,
onError: (err, info) => report(err, info.name),
});
Point the Vite plugin at it and dev reloads it on edit:
sigxServer({ serverApp: '/src/server-app.ts' })
The posture fields are inherited by every mount and by bare
handleServerFnRequest calls; an explicit per-call value wins. Mounts claim
their base namespace, and overlapping prefixes throw at mount time —
everything after the base is the symbol, so a partly-wrong base is worse than a
missing one.
The config is frozen once stamped, so the pipeline cannot be weakened in place
at runtime — reconfiguring means stamping a new app, and in tests it means
re-stamping through stubServerApp. See
the frozen config.
The runtime is fail-closed
A function that declares nothing, with no app configured, denies with 401. There is no "ran open" state to forget your way into.
// Denies. There is no declaration and no app default.
export const health = serverFn(async () => 'ok');
// Deliberately public.
export const health = serverFn({
allowAnonymous: true,
handler: async () => 'ok',
});
allowAnonymous is a word rather than an omission on purpose: "I meant this to
be public" and "I forgot" must not look identical. It keeps the app's open
surface greppable — grep -rn allowAnonymous --include='*.server.ts' src/ prints
every endpoint that deliberately waives identity.
It waives only the identity gate. Middleware and authentication still run for these functions, which is the point — a rate limiter applies to your sign-in endpoint, and a throwing authenticator surfaces there too. Declared policies still run, with a nullable principal.
export const submitPat = serverFn({
allowAnonymous: true, // deliberate: this IS the sign-in
form: true,
input: PatSchema,
handler: async (rq, pat) => exchangeToken(pat),
});
Because the runtime denies rather than trusting a static analysis, a *.server.ts
the build never analyzes is no longer a hole: an unanalyzed module denies instead
of running open.
Writing a policy
A ServerPolicy is (principal, rq, op) => boolean, strict-true to allow:
// src/policies.ts
import type { ServerPolicy } from '@sigx/server';
export const requireStaff: ServerPolicy = (p) => p?.roles.includes('staff') === true;
// Because policies run after validation, op.input is trusted and typed.
export const ownsCart: ServerPolicy = (p, rq, op) =>
p != null && (op.input as { cartId: string }).cartId.startsWith(`${p.id}:`);
Share policies, one imported identifier per function — there is no preset factory to derive from, and the app default covers the common case:
// src/board.server.ts
import { requireStaff } from './policies';
export const boardIssues = serverFn({ input: BoardKey, authorize: requireStaff, handler });
export const feed = serverStream({ authorize: requireStaff, handler: async function* (rq) { … } });
Reading the identity anywhere on the request:
import { principal, requirePrincipal } from '@sigx/server';
principal(rq) // Principal | null — memoized once per request store
requirePrincipal(rq) // Principal — throws 401 when absent
principal(rq) is memoized per request, so one SSR render with five cells
decodes the session once.
Rate limiting is middleware
Rate limiting is the clearest case for the middleware stage rather than a policy: it is not about who the caller is or what they may do, it must run before the body is read, and no function should be able to opt out of it.
// src/server-app.ts
import { createServerApp, ServerFnError, type ServerMiddleware } from '@sigx/server';
const rateLimit: ServerMiddleware = async (rq, fn) => {
if (fn.transport !== 'wire') return; // in-process SSR calls are not traffic
const ip = rq.request.headers.get('cf-connecting-ip') ?? 'unknown';
if (!(await bucket.take(ip, fn.name))) {
throw new ServerFnError(429, 'Slow down');
}
};
createServerApp({ middleware: [rateLimit], /* … */ });
fn.transport is the gate — an SSR render calling the same function in-process
is your own code, not inbound traffic. Do not infer this from fn.symbol;
symbol is pure identity, and '' only ever means "no build stamp".
Because middleware runs for allowAnonymous functions too, this covers the
sign-in endpoint — the one that most needs it.
Pair it with maxResponseBytes for the other direction: a limiter bounds how
often a caller can ask, and maxResponseBytes bounds how much any one answer can
return.
Migrating from 0.14
| 0.14 | 0.15 |
|---|---|
use: [guard] | Authorization → authorize: ServerPolicy | ServerPolicy[]. Cross-cutting work → app middleware. |
unguarded: true | allowAnonymous: true — but middleware and authentication now run for it. |
serverFnPreset({ use }) / preset.stream | The app default (createServerApp({ authorize })), or one imported policy identifier per function. |
endpoint guard option | App middleware — same pre-decode slot, and it reaches in-process calls too. Wire-only behaviour is if (fn.transport !== 'wire') return. |
info.symbol === '' to detect in-process | ServerFnInfo.transport. symbol is pure identity; '' only ever means "no build stamp". |
a bare serverFn({ handler }) ran open | It denies 401. Configure an app, or declare. |
The order change is the one to read twice: policies now run after input
validation, where the old guards ran before it. That is what makes authorizing on
the payload safe.
cache.public without allowAnonymous: true now dev-warns —
a shared-cache header over an authenticated response serves one caller's copy
to the next.
Note: a spread inside a
serverFn({ ... })options literal hidesid,cache,invalidatesandformfrom the static readers — which silently disables single-flight boundary refresh. The build warns about it.
Per-request values
A value derived from the request — a decoded session, an authenticated API
client, a request id — usually needs to be shared by a guard, a handler, and
every other server function the same render calls. perRequest(setup) computes
it at most once per request and hands back the accessor:
// src/session.server.ts
import { perRequest, ServerFnError } from '@sigx/server';
export const session = perRequest(async (rq) =>
decodeSession(rq.request.headers.get('cookie')));
export const github = perRequest(async (rq) => {
const s = await session(rq); // the SAME memoized promise
if (!s) throw new ServerFnError(401, 'Sign in');
return createGitHubClient(s.token);
});
// src/server-app.ts — authentication reads the same memoized promise
createServerApp({ authenticate: (rq) => session(rq), authorize: requireAuthenticated });
// src/board.server.ts
export const boardIssues = serverFn({
input: BoardKey,
handler: async (rq, key) => (await github(rq)).issues(key), // no decode, no cast
});
Without this, a page with five SSR-enabled useData cells decodes the same
session five times — cookie parse, signature verify, database read, decrypt, per
call.
- The accessor takes
rq. There is no ambient no-argument form; the context stays a parameter, which is the rulerqitself follows. - Values compose by calling each other. There is no composition API.
- The memoized promise is shared, so a guard and a handler racing on first touch get one in-flight decode.
- A failed setup stays failed for that request — retrying a failed decode once per cell would be a footgun. A setup that resolves itself throws "circular request value".
perRequest and rq.locals are two faces of the same store, not two equals.
rq.locals is the untyped escape hatch, for a value too small or transient to
name. A per-request value is the recommended hand-off: it types itself from its
own setup, and the accessor is the only way to reach it, so there is nothing to
cast.
Disposal
A setup's second parameter registers teardown:
const dbConn = perRequest(async (rq, onDispose) => {
const conn = await pool.connect();
onDispose(() => conn.release());
return conn;
});
Disposers run LIFO, each awaited, and a throw is logged and swallowed so one bad disposer cannot strand the rest. They run when the request is really over, which is a different moment per path:
| Path | Disposes at |
|---|---|
| Endpoint call | the work promise's settle — never the timeoutMs race, so a 504 does not yank resources out from under a still-settling handler |
serverStream | after the generator's return() settles, so a finally reading a per-request value never races its own teardown |
| Streamed edge response | end of body, via the keepAlive(until) seam the fetch handler calls with a body-settle promise |
That last row is why disposal could not exist before: on WinterCG runtimes the
render's scope settles at the shell, so "released once the response has flushed"
would have fired mid-stream. keepAlive is optional — an older package beside a
newer renderer degrades to the pre-disposal behaviour rather than breaking.
Ownership is claim-based per store: the endpoint first, then the outermost scope
entry. A fn.with({ context }) or otherwise detached store has no owner and
dev-warns; call disposeRequestValues(rq) yourself to trigger teardown there.
Where AsyncLocalStorage is unavailable — workerd without nodejs_compat —
there is no scope to share, so a value is computed per invocation. The guards
and handler of one call still share it; nothing throws and nothing splits.
With .with({ context }), passing the same { request, locals } object
shares one store across explicit calls. A fresh Request per call is its own
store, which is how a test isolates calls.
The request context
The first parameter carries everything about the current request:
interface ServerFnContext {
request: Request; // the WinterCG request — headers, cookies, method
url: URL; // the parsed request URL
abortSignal: AbortSignal; // fires when the client disconnects
responseHeaders: Headers; // mutable — set cookies/headers on the response
status(code: number): void; // override the success status code
locals: Record<string, unknown>; // guard → handler hand-off (e.g. the auth result)
}
Pass rq.abortSignal straight to any fetch you make so upstream work is
cancelled when the caller goes away:
export const search = serverFn(async (rq, q: string) => {
const res = await fetch(`https://api.example.com/search?q=${q}`, {
signal: rq.abortSignal,
});
return res.json();
});
Calling a server function during SSR
When a server function runs in-process during server rendering (rather than
over HTTP), there is no incoming HTTP request to expose, so the default context
is detached: reading rq.request or rq.url throws a descriptive error.
Supply a request when you need one:
- Per call —
await getCart.with({ context: request })(cartId). Works on every runtime;.with({ context })takes aRequestor a partialServerFnContext. - Ambient — wrap a region in
runWithServerFnContext(request, () => …)from@sigx/server/node(backed byAsyncLocalStorage). The Node and edge request handlers already open this scope, so most apps get an ambient context for free and never call either API.
The ambient form also takes a source object, which is how you pre-seed a render:
runWithServerFnContext({ request, locals: { user } }, () => renderHandler(…));
Nested scopes for the same request merge rather than replace, so the seed
survives the inner scope a document handler opens with the raw request: the
inner source's fields win where it supplies them, and the enclosing locals
stays the request's store. "Same request" means same URL and method — protocol
is excluded, so a TLS-terminating proxy doesn't split one request in two.
A source naming no request always merges, which makes
runWithServerFnContext({ locals }, …) the simplest pre-seed. A genuinely
different request gets its own store, plus a once-per-process development notice
naming both. To isolate a nested render deliberately, hand it its own locals.
Cacheable reads
By default every call is a POST. Declaring cache marks a function a
side-effect-free idempotent read: the stub issues GET with the arguments in
the query string, and the endpoint emits Cache-Control, so browser and edge
caches can absorb the traffic before it reaches your server.
export const listProducts = serverFn({
id: 'catalog',
input: z.object({ category: z.string() }),
allowAnonymous: true,
cache: { maxAge: 60, staleWhileRevalidate: 300, public: true },
handler: async (rq, { category }) => db.products.byCategory(category),
});
interface ServerFnReadCache {
maxAge: number; // seconds fresh in HTTP caches. No default — you choose.
staleWhileRevalidate?: number; // seconds a stale answer may still be served
public?: boolean; // opt into SHARED caches
sMaxAge?: number; // shared-cache TTL; defaults to maxAge
}
The emitted header follows directly from the declaration:
| Declaration | Cache-Control |
|---|---|
{ maxAge: 60 } | private, max-age=60 — plus Vary: Cookie |
{ maxAge: 60, staleWhileRevalidate: 300 } | private, max-age=60, stale-while-revalidate=300 |
{ maxAge: 60, public: true } | public, max-age=60, s-maxage=60 |
{ maxAge: 60, public: true, sMaxAge: 600 } | public, max-age=60, s-maxage=600 |
POST stays valid for a cache-marked function — the GET is what makes it
cacheable, not the only way to call it.
The two promises you are making
cache asserts the function does not mutate. A GET read drops the JSON
content-type CSRF gate, so marking a mutating function cache re-opens CSRF
completely. Nothing checks this for you; it is the author's promise.
public: true asserts the output depends only on the arguments — never
cookies, never auth, never a request header. A shared cache will serve one user's
response to another. Without it, the endpoint emits private and appends
Vary: Cookie.
cache with invalidates is a definition-time error — a
read that invalidates is not a read. So is cache with form: true; a form
target is a mutation. Both throw where the function is defined rather than
silently preferring one, so the contradiction cannot reach a deploy.
Bypass the HTTP cache for a single call with
.with({ fresh: true }).
cacheis the HTTP layer.@sigx/cache'sstaleTimeis the in-app layer, and the two compose:cachedecides what a browser or CDN may reuse without asking,staleTimedecides when a mounted read refetches.
What the GET looks like
When every argument is a simple scalar, a cache-marked read rides as named params rather than an encoded blob:
GET /_sigx/fn/catalog/listProducts?a0=shoes
Types survive because a param reads back as a number, true, false or null
only when its raw text says so. A string that would otherwise be misread is
JSON-quoted — ?a0="42" — which is the only escape a scalar read can still
produce.
Anything richer (objects, Date, Map, Set, BigInt) falls back to the
?args= blob for the whole call, never a mix, so the cache key stays a pure
function of the arguments. Mixing the two explicitly, or leaving a gap in the
a0, a1, … sequence, is a 400 rather than a call with silently shifted
arguments.
Long argument lists are the one operational limit — the query string is capped at
maxUrlBytes (default 8 KiB) and answered with a 414 past it.
Zero-JS form actions
form: true marks a function a form target. A <form> whose submit handler
calls it gets a real action and method="post" stamped in at build time, so the
form works before — and without — JavaScript. With JS, the same function runs as
ordinary RPC. One function, one validator, two transports.
export const submitFeedback = serverFn({
form: true, // must be the literal `true`
input: z.object({
email: z.string().email(),
rating: z.coerce.number().int().min(1).max(5),
message: z.string().min(1),
}),
allowAnonymous: true,
handler: async (rq, feedback) => db.feedback.add(feedback),
});
input is required — a form target without one is a definition-time error.
Form fields are attacker-typable strings, and the validator is the only thing
between them and your handler.
FormData becomes your input
The body is normalized into the function's single input as a flat object:
| In the form | In your input |
|---|---|
One field named email | email: string |
Repeated fields named tag | tag: string[] |
<input type="file"> | the File, passed through |
| Anything else | still a string |
Values stay strings because that is what the platform gives you — Standard Schema
coercion is the mapping tool, which is why z.coerce.number() appears above and a
bare z.number() would reject every submission. Prototype-polluting field names
are dropped, the same posture the JSON reviver takes.
The round trip
A successful no-JS submit answers 303 See Other, so a reload doesn't resubmit. The redirect target is, in order:
- a
Locationyour handler set onrq.responseHeaders; - the request's
Referer— only if same-origin, and only its path and query; /.
An attacker-controlled Referer must never become an open redirect, which is why
step 2 is same-origin-only. A handler that sets its own status via rq.status()
is honoured verbatim, with no default Location added.
A validation failure without JS renders a minimal HTML page — in production a
plain "please go back and correct it", in development the actual issues. It is a
backstop, not a UX: native HTML validation (required, type="email",
pattern=) is the first line, because it never round-trips at all.
Security: what you are giving up
A declared form target deliberately drops the JSON content-type CSRF layer —
it has to, because a native form cannot send application/json. Everything else
stays at full strength:
- The Origin check stays on. An Origin-less form POST is
403under every policy short oforigin: false— including'verify-when-present', which relaxes the check for JSON callers but not for forms. inputis mandatory, so the validator always runs.- Only
origin: false— the explicit public-endpoint escape hatch — reopens classic CSRF on a form target. Don't, unless it is genuinely public.
'verify-when-present' used to admit Origin-less form posts, and that quietly
reopened CSRF: the relaxation's safety rests on the JSON content-type gate, which
a form target deliberately gives up. It no longer does. Form posts carrying a
matching Origin, and the whole JSON path, are unaffected.
form with cache is a definition-time error — a form target is a mutation.
When stamping happens
The action is stamped by the resume extractor,
so it reaches only files matching sigxResume()'s include and only components
that extract in resume mode. The rules and their edges:
- Exactly one form-marked capture per
<form>. - An
action/methodyou wrote by hand wins — stamping never overwrites it. role: 'client'builds and hydrate-mode components never stamp.- A form-target import via a bare specifier does not stamp.
A <form> outside those rules still submits through the server function as RPC
once its component is running. It just has no native fallback before then — and
the build warns when a form: true form will never be stamped, so this is
visible rather than silent.
Per-call options
Every wrapped function carries a .with(options) channel that returns the same
callable with those options bound. It is a separate channel on purpose: your
arguments stay exactly your arguments, with no trailing-options parameter for the
transport to sniff.
const cart = await getCart.with({ signal: controller.signal })(cartId);
interface ServerFnCallOptions {
signal?: AbortSignal;
headers?: Record<string, string>;
fresh?: boolean;
context?: Request | Partial<ServerFnContext>;
}
| Option | Where it applies | Effect |
|---|---|---|
signal | Everywhere | Aborts the in-flight call. On the client the fetch is aborted; on an in-process (SSR) call it becomes rq.abortSignal. |
headers | Client transport only | One-off request headers for this call, merged over configureServerFn's transport headers — the per-call value wins. content-type is never overridable (stripped case-insensitively; the endpoint 415s anything else). |
fresh | Client transport only, cache-marked GET reads | Sets cache: 'no-cache' on the fetch, so the browser revalidates with the origin instead of answering from max-age. |
context | In-process (SSR) only | The request this call should see — see Calling a server function during SSR above. Wins over the ambient runWithServerFnContext scope. |
The two "client only" options are no-ops in-process, and context is a no-op on
the client — each warns in development rather than failing, because the same code
legitimately runs on both sides. The mirror is deliberate: an in-process call
makes no HTTP request, so there are no headers to send and no cache to bypass; a
client stub's context is the request it makes, and silently accepting one there
would imply it travelled.
fresh is meaningless on a POST, which is never HTTP-cached, so it is a
dev-warned no-op there too.
Streams take the same channel minus fresh. A serverStream is always POST
and therefore never answered from an HTTP cache, so ServerStreamCallOptions
omits the option entirely — using it is a compile error rather than a silent
no-op:
for await (const line of tailLog.with({ signal, headers: { 'x-trace': id } })(jobId)) {
console.log(line);
}
Errors
Throw ServerFnError to send a typed, client-visible failure. It crosses the
wire verbatim as { status, message, data? }:
import { serverFn, ServerFnError, isServerFnError } from '@sigx/server';
export const publish = serverFn(async (rq, id: string) => {
const post = await db.posts.find(id);
if (!post) throw new ServerFnError(404, 'No such post');
if (post.locked) throw new ServerFnError(409, 'Post is locked', { id });
return db.posts.publish(id);
});
try {
await publish(id);
} catch (err) {
if (isServerFnError(err) && err.status === 409) {
// err.message and err.data are the values thrown on the server
}
}
Every other thrown error is masked: in production the client sees a generic
500, and the original never fires the app's onError. ServerFnError is the
deliberate channel for failures the caller is meant to handle.
Streaming
serverStream returns values over time. The implementation is an async
generator; the client stub is an AsyncIterable you can for await over, and a
string stream drops straight into useStream:
// src/chat.server.ts
import { serverStream } from '@sigx/server';
export const streamReply = serverStream(async function* (rq, prompt: string) {
const completion = await llm.stream(prompt, { signal: rq.abortSignal });
for await (const token of completion) yield token; // sent as NDJSON
});
const reply = useStream(() => ['reply', prompt], streamReply);
return () => <p>{reply.text}</p>;
Streams are lazy — the request starts on the first iteration, and a consumer
that breaks or returns early aborts the fetch and runs the generator's
finally. Response status and headers freeze at the first yield.
serverStream has the same two authoring forms as serverFn. The options form
carries authorize, allowAnonymous and handler:
export const feed = serverStream({
authorize: requireAuthenticated,
async *handler(rq, room: string) {
for await (const msg of subscribe(room, rq.abortSignal)) yield msg;
},
});
Declaring an input
Adding input selects a single-input form, shaped exactly like serverFn's:
export const feed = serverStream({
input: z.object({ room: z.string(), since: z.coerce.number().optional() }),
authorize: requireAuthenticated,
async *handler(rq, { room, since }) {
for await (const msg of subscribe(room, since, rq.abortSignal)) yield msg;
},
});
The schema runs after the policies and before the first chunk, on every
transport. Over the wire a rejection is a buffered JSON 400 { issues } — headers
are still writable and no stream byte has been sent; in-process it rejects on the
first pull. With input declared the stream takes exactly one argument, extra
wire args are a 400, and the handler receives the validated value rather than the
raw wire arg.
Omitting input never falls back to the handler's annotation — no input means
the multi-argument form above.
An authorization denial surfaces on the first pull rather than at the call, which is where the wire path's pre-first-yield errors surface too. Streams run the same pipeline in-process during SSR as they do over the wire.
Next steps
- Deployment & security — serve the endpoint in dev, Node and edge runtimes, and the security defaults you're getting.
- Native clients — pointing a lynx or terminal app's stubs at a remote backend.
- Data Loading —
useData/useAction/useStream, the value-first primitives server functions feed.
