Server/Packages/Server Functions/API reference
@sigx/server · Stable

API reference#

Exports of @sigx/server v0.15.3. See Authoring for the guide and Deployment & security for the runtime wiring.

Authoring (main entry)#

TypeScript
import { serverFn, serverStream, ServerFnError, isServerFnError } from '@sigx/server';

serverFn#

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

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

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

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

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

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

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

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

TypeScript
interface ServerFnContext {
  request: Request;
  url: URL;
  abortSignal: AbortSignal;
  responseHeaders: Headers;
  status(code: number): void;
  locals: Record<string, unknown>;
}

ServerFnInfo#

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

TypeScript
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/nodecreateServerFnHandler(options) (a connect-style middleware) and runWithServerFnContext(source, fn), where source is a Request or a partial { request?, locals? }. Nested scopes for the same request (same URL and method) merge, so runWithServerFnContext({ 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 imports createServerApp needs no second import path.
  • @sigx/server/plugin — the app-plugin face: serverPlugin({ transport?, types? }), registerWireTypeHandlers(handlers). The types option registers @sigx/serialize handlers for the RPC wire and the SSR state registry in one call.
  • @sigx/server/testingcreateTestServerFnContext(init?, { principal }) and stubServerApp(config). See Testing below.

See Deployment & security for how these are mounted.

Testing#

TypeScript
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 mutatingthe stamped config is frozen:

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

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