Server/Packages/Server Functions/Authoring
@sigx/server · Stable

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.

TypeScript
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:

TypeScript
// 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.

TSX
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.

TypeScript
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.

TypeScript
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:

FieldPurpose
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.
handlerThe 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:

StageWhere it livesWhat it is for
Middlewareapp-wide, createServerApp({ middleware })cross-cutting work that always runs — logging, tracing, rate limiting, response headers. Never per-function disableable.
Authenticationapp-wide, createServerApp({ authenticate })deciding who the caller is, once. Produces the Principal.
Authorizationper 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:

TypeScript
// 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:

JavaScript
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.

TypeScript
// 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.

TypeScript
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:

TypeScript
// 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:

TypeScript
// 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:

TypeScript
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.

TypeScript
// 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.140.15
use: [guard]Authorization → authorize: ServerPolicy | ServerPolicy[]. Cross-cutting work → app middleware.
unguarded: trueallowAnonymous: true — but middleware and authentication now run for it.
serverFnPreset({ use }) / preset.streamThe app default (createServerApp({ authorize })), or one imported policy identifier per function.
endpoint guard optionApp 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-processServerFnInfo.transport. symbol is pure identity; '' only ever means "no build stamp".
a bare serverFn({ handler }) ran openIt 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 hides id, cache, invalidates and form from 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:

TypeScript
// 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 rule rq itself 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:

TypeScript
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:

PathDisposes at
Endpoint callthe work promise's settle — never the timeoutMs race, so a 504 does not yank resources out from under a still-settling handler
serverStreamafter the generator's return() settles, so a finally reading a per-request value never races its own teardown
Streamed edge responseend 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:

TypeScript
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:

TypeScript
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 callawait getCart.with({ context: request })(cartId). Works on every runtime; .with({ context }) takes a Request or a partial ServerFnContext.
  • Ambient — wrap a region in runWithServerFnContext(request, () => …) from @sigx/server/node (backed by AsyncLocalStorage). 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:

TypeScript
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.

TypeScript
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),
});
TypeScript
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:

DeclarationCache-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 }).

cache is the HTTP layer. @sigx/cache's staleTime is the in-app layer, and the two compose: cache decides what a browser or CDN may reuse without asking, staleTime decides 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.

TypeScript
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 formIn your input
One field named emailemail: string
Repeated fields named tagtag: string[]
<input type="file">the File, passed through
Anything elsestill 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:

  1. a Location your handler set on rq.responseHeaders;
  2. the request's Refereronly if same-origin, and only its path and query;
  3. /.

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 403 under every policy short of origin: false — including 'verify-when-present', which relaxes the check for JSON callers but not for forms.
  • input is 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/method you 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.

TypeScript
const cart = await getCart.with({ signal: controller.signal })(cartId);
TypeScript
interface ServerFnCallOptions {
  signal?: AbortSignal;
  headers?: Record<string, string>;
  fresh?: boolean;
  context?: Request | Partial<ServerFnContext>;
}
OptionWhere it appliesEffect
signalEverywhereAborts the in-flight call. On the client the fetch is aborted; on an in-process (SSR) call it becomes rq.abortSignal.
headersClient transport onlyOne-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).
freshClient transport only, cache-marked GET readsSets cache: 'no-cache' on the fetch, so the browser revalidates with the origin instead of answering from max-age.
contextIn-process (SSR) onlyThe 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:

TypeScript
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? }:

TypeScript
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);
});
TSX
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:

TypeScript
// 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
});
TSX
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:

TypeScript
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:

TypeScript
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 LoadinguseData / useAction / useStream, the value-first primitives server functions feed.