Server/Packages/Server Functions/Deployment & security
@sigx/server · Stable

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:

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

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

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

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

JavaScript
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 POST with 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': the Origin header 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: nor allowAnonymous: true, under no configured app, denies with 401 rather 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 at maxUrlBytes (default 8 KiB → 414).
  • An immutable control seam — the app config carrying middleware, authenticate, authorize, posture and codec is 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-ServerFnError throw becomes a generic 500; 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:

OptionDefaultPurpose
resolveRequired. 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/fnThe 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.
maxBodyBytes1 MiBBody cap, enforced while reading. An error mid-read is a 400, not a 500 — a truncated upload is the caller's problem.
maxUrlBytes8 KiBCap on a GET read's query string → 414. Sits under mainstream proxies' request-line caps.
maxResponseBytesnoneOpt-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.
onErrorObservability seam for masked failures.
timeoutMsnoneUpper 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.
renderBoundariesSingle-flight boundary refresh; createBoundaryRefresh from @sigx/resume/server builds one. Required for boundary refresh to happen at all — omitting it fails silently.
authorizeBoundaryVetoes 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:

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

TypeScript
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 createServerApp and 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, as authorize:.

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 function cache when it is genuinely side-effect-free.
  • form: true gives up the JSON-CSRF layer so the function can accept a progressively-enhanced HTML form post; the origin check stays at full strength, and input is required.
  • public: true on a read is a promise that its output depends only on its arguments — no cookies, auth or per-user headers.
  • origin can be widened from 'same-origin' to 'verify-when-present' (to admit Origin-less programmatic clients), an explicit allowlist, or false for a deliberately public API.

Next steps#