API reference
Exports of @sigx/server v0.15.3. See Authoring for the guide and Deployment & security for the runtime wiring.
Authoring (main entry)
import { serverFn, serverStream, ServerFnError, isServerFnError } from '@sigx/server';
serverFn
// direct form — context + typed args
function serverFn<A extends unknown[], R>(
impl: (rq: ServerFnContext, ...args: A) => R | Promise<R>
): ServerFnCallable<A, Awaited<R>>;
// options form — one validated input
function serverFn<S = void, R = unknown>(
options: ServerFnOptions<S, R>
): ServerFnCallable<[S] extends [void] ? [] : [S], Awaited<R>>;
The client stub is (...args) => Promise<R> — the context is server-only and never passed by callers. ServerFnOptions carries input (a Standard Schema), authorize (policies), allowAnonymous, handler, invalidates, cache, form and id — see Authoring.
createServerApp
function createServerApp(config: ServerAppConfig): ServerApp;
interface ServerAppConfig {
middleware?: ServerMiddleware[]; // always runs, every transport, pre-decode
authenticate?: (rq: ServerFnContext) => Principal | null | Promise<Principal | null>;
authorize?: ServerPolicy | ServerPolicy[]; // the app-wide default
codec?: PrincipalCodec; // cross-hop propagation (@sigx/actors)
origin?: OriginPolicy; // ─┐
maxBodyBytes?: number; // │ endpoint posture, stated once and
maxUrlBytes?: number; // │ inherited by every mount and by
maxResponseBytes?: number; // │ bare handleServerFnRequest calls;
timeoutMs?: number; // │ an explicit per-call value wins
onError?: (err: unknown, info: ServerFnInfo) => void; // ─┘
authorizeBoundary?: (d: BoundaryDescriptor, rq: ServerFnContext) => boolean;
}
interface ServerApp {
serverFns(mount?: ServerFnRequestOptions): (request: Request) => Promise<Response>;
dispose(): void;
}
The one user-owned value holding everything app-wide. app.serverFns(mount) returns a plain handler bound to the app — routing stays in the platform entry, and matchesServerFn is still the predicate. The app decides what every operation passes through, never which handler answers a URL.
Mounts claim their base namespace: overlapping prefixes throw at mount time, because everything after the base is the symbol. Stamping the __SIGX_SERVER_APP__ seam is last-wins, and dispose() releases it only if it still holds this app's stamp.
The config is frozen once stamped
__SIGX_SERVER_APP__ is the fail-closed control seam — middleware, authenticate, authorize, posture and codec. Stamping freezes the config object, so swapping a member in place throws:
globalThis.__SIGX_SERVER_APP__.authorize = () => true; // TypeError
This applies to the object you pass createServerApp and to the one you pass stubServerApp alike; resolveServerAppConfig() likewise returns a frozen object.
The freeze is shallow, so claimedBases still accepts push and mounting is unaffected.
Upgrading from 0.15.2 or earlier? Mutating a member used to warn nothing
and silently fail open — an authorization bypass — which is why the throw shipped
in a patch. The pattern that breaks is a test suite that stamps once and then swaps
authorize or authenticate between cases. Re-stamp through
stubServerApp instead; see Testing.
ServerPolicy
type ServerPolicy = (principal: Principal | null, rq: ServerFnContext, op: ServerOp) => boolean;
function principal(rq: ServerFnContext): Principal | null; // memoized per request
function requirePrincipal(rq: ServerFnContext): Principal; // throws 401 when absent
function setPrincipal(rq: ServerFnContext, p: Principal | null): void;
const requireAuthenticated: ServerPolicy; // the built-in default
Strict-true to allow — any other return is a 403, or a 401 when the principal is null. Policies run after input validation, so op.input is the trusted, parsed resource. (The old use: guards ran before validation, which is why they could never safely authorize on the payload.)
principal(rq) is memoized once per request store, so one SSR render with five cells decodes the session once.
perRequest
function perRequest<T>(
setup: (rq: ServerFnContext, onDispose: (fn: () => void | Promise<void>) => void) => T
): (rq: ServerFnContext) => T;
function disposeRequestValues(rq: ServerFnContext): Promise<void>;
Declares a value derived from the request and computed at most once per request, shared by every middleware, policy, handler and nested in-process call in that flow. Returns the accessor, which is the only way to reach the value; values compose by calling each other. A rejected setup stays rejected for the request.
The setup's second parameter registers teardown. Disposers run LIFO, each awaited, throws logged and swallowed. Ownership is claim-based per store — the endpoint first, then the outermost scope entry; a detached store (fn.with({ context })) has no owner, dev-warns, and is the app's job to tear down via disposeRequestValues(rq). See Per-request values.
serverStream
function serverStream<A extends unknown[], T>(
impl: (rq: ServerFnContext, ...args: A) => AsyncGenerator<T>
): ((...args: A) => AsyncIterable<T>) & WrappedServerFn;
function serverStream<A extends unknown[], T>(
options: ServerStreamOptions<A, T>
): ((...args: A) => AsyncIterable<T>) & WrappedServerFn;
// multi-argument options form
interface ServerStreamOptions<A extends unknown[], T> {
authorize?: ServerPolicy | ServerPolicy[];
allowAnonymous?: true;
handler(rq: ServerFnContext, ...args: A): AsyncGenerator<T>;
}
// single-input options form — selected by declaring `input`
interface ServerStreamInputOptions<S, T> {
input: StandardSchemaV1<S>;
authorize?: ServerPolicy | ServerPolicy[];
allowAnonymous?: true;
handler(rq: ServerFnContext, input: S): AsyncGenerator<T>;
}
Streams yield as NDJSON; a string stream plugs into useStream. An authorization denial surfaces on the first pull.
Declaring input selects the single-input form. Any Standard Schema works (Zod, Valibot, ArkType). 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, exactly where an authorization denial surfaces.
- The stream then takes one argument; extra wire args are a 400, and the validated value (not the raw wire arg) reaches the handler.
Omitting input never falls back to the handler's annotation — no input means the multi-argument form.
Per-call options
Every wrapped callable carries .with(options), returning the same signature with
those options bound.
type ServerFnCallable<A extends unknown[], R> = ((...args: A) => Promise<R>) & {
with(options?: ServerFnCallOptions): (...args: A) => Promise<R>;
/** The build-stamped stable data key, `<stableId>#<name>`. */
__sigxKey: string;
} & WrappedServerFn;
interface ServerFnCallOptions {
/** Aborts the call — the client fetch, or `rq.abortSignal` in-process. */
signal?: AbortSignal;
/** Client transport only. Merged over the transport headers; `content-type` is not overridable. */
headers?: Record<string, string>;
/** Client transport only, cache-marked GET reads. Sets `cache: 'no-cache'` on the fetch. */
fresh?: boolean;
/** In-process (SSR) only. The request this call should see; beats the ambient scope. */
context?: Request | Partial<ServerFnContext>;
}
/** A stream's channel — the same, minus `fresh` (a stream is never HTTP-cached). */
type ServerStreamCallOptions = Omit<ServerFnCallOptions, 'fresh'>;
Options that do not apply to the current transport are dev-warned no-ops, not errors — see Per-call options.
configureServerFn
function configureServerFn(config: ServerFnTransport | null): void;
interface ServerFnTransport {
/** Absolute URL or path prefix; wins over the build-time endpoint. */
endpoint?: string;
/** Static map, or a (possibly async) factory called per request. */
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
/** Fetch implementation; defaults to the global fetch. */
fetch?: typeof globalThis.fetch;
}
From @sigx/server/client. Sets the transport every stub resolves at call
time, so one build serves dev, staging and production. Pass null to clear it.
See Native clients.
ServerFnContext
interface ServerFnContext {
request: Request;
url: URL;
abortSignal: AbortSignal;
responseHeaders: Headers;
status(code: number): void;
locals: Record<string, unknown>;
}
ServerFnInfo
interface ServerFnInfo {
/** Pure identity. `''` means "no build stamp" — never a transport signal. */
symbol: string;
name: string;
transport: 'wire' | 'in-process';
}
transport is the discriminator for middleware that should only apply to real HTTP traffic — a rate limiter's gate line is if (fn.transport !== 'wire') return. Do not infer it from symbol.
Errors
class ServerFnError extends Error {
readonly status: number;
constructor(status: number, message: string, data?: unknown);
}
function isServerFnError(error: unknown): error is ServerFnError;
ServerFnError crosses the wire verbatim ({ status, message, data }) and never fires onError; every other throw is masked to a generic 500 in production.
Runtime entries
@sigx/server/node—createServerFnHandler(options)(a connect-style middleware) andrunWithServerFnContext(source, fn), wheresourceis aRequestor a partial{ request?, locals? }. Nested scopes for the same request (same URL and method) merge, sorunWithServerFnContext({ locals }, …)pre-seeds a render that opens its own inner scope.@sigx/server/server— WinterCG/edge:handleServerFnRequest(request, options),matchesServerFn(request, base?). Both are also re-exported from the package root, so a platform entry that already importscreateServerAppneeds no second import path.@sigx/server/plugin— the app-plugin face:serverPlugin({ transport?, types? }),registerWireTypeHandlers(handlers). Thetypesoption registers@sigx/serializehandlers for the RPC wire and the SSR state registry in one call.@sigx/server/testing—createTestServerFnContext(init?, { principal })andstubServerApp(config). See Testing below.
See Deployment & security for how these are mounted.
Testing
import { createTestServerFnContext, stubServerApp } from '@sigx/server/testing';
const restore = stubServerApp({ authenticate: () => ({ id: 'u_1', roles: ['staff'] }) });
try {
const rq = createTestServerFnContext({ url: 'http://localhost/checkout' });
await placeOrder.with({ context: rq })(cart);
} finally {
restore();
}
createTestServerFnContext(init?) builds a real, Request-backed ServerFnContext (default http://localhost/) with none of the ceremony: rq.request and rq.url never throw the detached-context error, rq.status(code) records to a readable .statusCode instead of dev-warning, and caller locals keep their identity — one factory context reused across several fn.with({ context: ctx }) calls is one request store, while two contexts are two.
stubServerApp(config) stamps the app seam for a test and returns the restore. Call it in teardown: the seam is process-global and last-wins, so an un-restored stamp leaks into every test that follows. Because the runtime is fail-closed, a serverFn under no app denies with 401, so a unit test that exercises a handler needs either a stubbed app or an allowAnonymous: true declaration.
Varying the config between cases means re-stamping, not mutating — the stamped config is frozen:
let restore: (() => void) | undefined;
afterEach(() => {
restore?.();
restore = undefined; // so a test that stamps nothing restores nothing
});
it('denies a non-owner', async () => {
restore = stubServerApp({ authorize: ownsIt });
// …
});
For a plain unit test of a single handler, prefer createTestServerFnContext(init, { principal }) — injecting the identity tests the handler rather than the cookie parser.
Extending the pipeline
ServerFeatureContext is the seam for an endpoint family other than serverFns — @sigx/actors is the first — so two families cannot drift apart on the auth path. It exposes prelude (middleware → authenticate → identity gate), enter (the same, from a raw Request), authorize (carrying op.resource, for per-instance policies), plus the app's posture, its principalCodec, and claimBase.
import { serverFeature } from '@sigx/server';
const feature = serverFeature(); // or app.feature() from a ServerApp
Both resolve __SIGX_SERVER_APP__ per call, so a feature can hold one at module scope and a call site with no platform value in scope still runs the whole pipeline. These are the same functions the serverFn path runs, not a second implementation — fail-closed throughout.
