Deployment & security
A server function is served at POST /_sigx/fn/<symbol>, where <symbol> is a
content-hashed id derived from the function. You wire that endpoint once per
runtime; everything else — validation, error masking, the security gate — is the
same everywhere.
The URL is percent-free. A stable symbol spends as real path segments, so it reads back as what you wrote:
POST /_sigx/fn/@acme/api/src/cart.server.ts/addToCart
POST /_sigx/fn/cart/add # serverFn({ id: 'cart/add' })
That also retires a deploy hazard: nothing in front of the app has to preserve encoded slashes any more. A proxy or CDN that decodes or merges them no longer mangles these routes.
Development
Zero config. Add the sigxServer() plugin from @sigx/vite/server to your Vite
config and the dev server handles the endpoint from its middleware, loading the
real *.server.ts modules on demand:
// vite.config.ts
import { defineConfig } from 'vite';
import sigx from '@sigx/vite';
import sigxServer from '@sigx/vite/server';
export default defineConfig({
plugins: [sigx(), sigxServer()],
});
sigxServer() options: include (default **/*.server.{ts,tsx}), exclude,
base (default /_sigx/fn), serverApp (a root-relative path to your
createServerApp module), and the full
endpoint posture — origin, maxBodyBytes, maxUrlBytes, maxResponseBytes,
timeoutMs and onError — so dev runs the same knobs described below.
sigxServer({ serverApp: '/src/server-app.ts' })
Dev loads that module eagerly through the SSR module runner and re-evaluates it
after edits, so the app-wide pipeline is live in development rather than
production-only. Production builds inject one side-effect import at the top of
virtual:sigx-server-fns.
The endpoint-level guard option is gone. App middleware runs at the same
pre-decode slot and reaches in-process SSR calls, which the wire-only guard
never could; a genuinely wire-only concern is one line in the middleware body:
if (fn.transport !== 'wire') return;
Production (Node)
The SSR build emits a registry chunk (dist/server/sigx-server-fns.js) that
maps every symbol to its implementation. Pass it explicitly to
createServerFnHandler from @sigx/server/node and mount the handler before
your document/SSR handler:
import { createServerFnHandler } from '@sigx/server/node';
import { createRequestHandler } from '@sigx/server-renderer';
const { serverFns } = await import('./dist/server/sigx-server-fns.js');
app.use(createServerFnHandler({
functions: serverFns, // the build's registry chunk — never ambient
}));
app.use(createRequestHandler({ /* your document handler, unchanged */ }));
createServerFnHandler returns a connect-style (req, res, next?) middleware,
so it drops into Express/Connect/Polka and most Node servers.
Edge (WinterCG)
For fetch-shaped runtimes (Workers, Deno, etc.), use @sigx/server/server.
Match the endpoint and hand the request off; anything else falls through to
createFetchHandler — the
production document renderer for every fetch-shaped platform:
import { createFetchHandler } from '@sigx/server-renderer/server';
import { handleServerFnRequest, matchesServerFn } from '@sigx/server/server';
const handler = createFetchHandler({ template, app: (url) => createApp(url) });
export default {
async fetch(request) {
if (matchesServerFn(request)) {
return handleServerFnRequest(request, { resolve: (symbol) => registry[symbol] });
}
return handler(request);
},
};
This composition — static assets → server functions → document render — is exactly what the deploy adapters scaffold as your platform entry on Cloudflare, Vercel and Netlify; security defaults are unchanged by deployment.
Security defaults
Every server function is a public HTTP endpoint, so the runtime hardens each one by default. You get all of this without opting in:
- POST-only, JSON required. Calls must be
POSTwith a JSON content type. Because that content type is not CORS-safelisted, a cross-site form can't forge one — this is the CSRF gate. The endpoint never emits CORS headers. - Origin check — default
'same-origin': theOriginheader must match the request's origin. - A fail-closed pipeline — middleware, then authentication, then the identity
gate, all before the wire payload is revived. A function that declares neither
authorize:norallowAnonymous: true, under no configured app, denies with401rather than running open. An anonymous attacker's payload never reaches the codec or the validator. - Body & URL limits — bodies are capped at
maxBodyBytes(default 1 MiB) during the read; GET reads are capped atmaxUrlBytes(default 8 KiB →414). - An immutable control seam — the app config carrying
middleware,authenticate,authorize, posture andcodecis frozen when it is stamped, so nothing can weaken the pipeline in place at runtime. Reconfiguring means stamping a new app, which is an act with an owner rather than a silent assignment. See the frozen config. - Prototype-pollution-safe parsing on both the client and server sides.
- Production error masking — any non-
ServerFnErrorthrow becomes a generic500; the original never leaves the server. - Structured version-skew
404— a stale client calling a symbol that no longer exists gets a typed 404, not a silent hang.
Request-handler options
handleServerFnRequest (edge) and createServerFnHandler (Node) take the same
ServerFnRequestOptions:
| Option | Default | Purpose |
|---|---|---|
resolve | — | Required. Symbol → wrapped function. Return null/undefined for unknown symbols; the stub surfaces that as a version-skew error. A throwing resolve is masked like any other failure — it does not leak. |
base | /_sigx/fn | The mount prefix. Everything after it is the symbol, so pass the same value you gave sigxServer({ base }) — serverFnBase from virtual:sigx-server-fns is the single source of truth. Overlapping mounts throw at mount time. |
origin | 'same-origin' | 'verify-when-present', an allowlist, or false. |
maxBodyBytes | 1 MiB | Body cap, enforced while reading. An error mid-read is a 400, not a 500 — a truncated upload is the caller's problem. |
maxUrlBytes | 8 KiB | Cap on a GET read's query string → 414. Sits under mainstream proxies' request-line caps. |
maxResponseBytes | none | Opt-in outbound cap — the missing analog of maxBodyBytes, for a function whose result is unbounded (an unfiltered query, a runaway generator). Measures actual UTF-8 bytes, never .length. A breach is a masked 500 through onError. |
onError | — | Observability seam for masked failures. |
timeoutMs | none | Upper bound on the middleware + handler. Note the timeout race never triggers request-value disposal — a 504 does not yank resources from a still-settling handler. |
renderBoundaries | — | Single-flight boundary refresh; createBoundaryRefresh from @sigx/resume/server builds one. Required for boundary refresh to happen at all — omitting it fails silently. |
authorizeBoundary | — | Vetoes ONE boundary descriptor under the request's principal, after the deps ∩ invalidates gate. Strict-true; a deny drops that descriptor silently, a throw drops the whole refresh. The mutation is never affected. |
Every posture option above is also settable once on
createServerApp and inherited by every
mount and by bare handleServerFnRequest calls; an explicit per-call value wins.
The symbol registry is a null-prototype object, so a symbol named
__proto__ or constructor cannot reach anything but a real registration.
onError
Called for every masked failure — any non-ServerFnError throw from
middleware, a policy, resolve or the handler, timeouts included — in dev and
production, before the client response is built:
handleServerFnRequest(request, {
resolve,
onError(error, info, rq) {
telemetry.report(error, { fn: info.name, url: rq.url.pathname });
},
});
ServerFnErrors are expected, client-visible failures and do not fire it —
this hook is for the errors the client never sees.
It is awaited, because an edge runtime may cancel post-response microtasks and lose fire-and-forget telemetry. Its own throws are swallowed and never affect the response.
timeoutMs
An upper bound on the middleware pipeline + handler, in milliseconds:
handleServerFnRequest(request, { resolve, timeoutMs: 10_000 });
On expiry the caller gets a 504, rq.abortSignal fires — alongside client
disconnect, via AbortSignal.any — and onError receives the timeout error. So
a handler that threads rq.abortSignal into its own fetch and database calls
gets those cancelled too; one that ignores it keeps running.
For a stream, the bound covers time to first chunk only. Once an NDJSON stream has started it is not bounded — cancel a long stream from the consumer side instead.
There is no default: without timeoutMs, a handler runs until it settles.
Everything here is app-wide, not per-transport. Configure the posture once on
createServerAppand every mount inherits it, in-process SSR calls included — there is no longer a wire-only layer to keep in sync with a definition-level one. Per-operation authorization stays on the function, asauthorize:.
Relaxing a default — deliberately
A few options weaken a default on purpose. Reach for them only when you mean to:
cache(a GET read) drops the JSON content-type CSRF gate — only mark a functioncachewhen it is genuinely side-effect-free.form: truegives up the JSON-CSRF layer so the function can accept a progressively-enhanced HTML form post; the origin check stays at full strength, andinputis required.public: trueon a read is a promise that its output depends only on its arguments — no cookies, auth or per-user headers.origincan be widened from'same-origin'to'verify-when-present'(to admit Origin-less programmatic clients), an explicit allowlist, orfalsefor a deliberately public API.
Next steps
- Authoring server functions — the definition surface these endpoints serve.
- Server renderer — pair RPC with streaming SSR and hydration.
