## Summary
This PR closes the third and final piece of the server-function security
series. PR #421 made the action token tamper-evident; PR #422 added the
kill-switch for apps that don't use server functions at all. What was
still missing — and what this lands — is a way to declare *what shape*
an action expects from the wire, so the runtime can reject malformed
payloads at the protocol layer instead of letting them reach handler
code where intent is lost.
The API is `createFunction`, exported from a new
`@lazarv/react-server/function` subpath. It wraps a `"use server"`
handler with a per-arg parse/validate spec, the bundler forwards that
spec to `registerServerReference`, and the protocol decoder consults it
on every call. Bad inputs are caught during decode and the request fails
with `HTTP 400` and an `x-react-server-action-error: <reason>` header
before any handler code runs. Bare `"use server"` actions without
`createFunction` keep working unchanged — validation is opt-in and
additive.
## The API
The most common shape is the array shorthand:
`createFunction([z.string(), z.number()])(handler)`. The slot index is
the *runtime arg slot* — what the client puts on the wire at position
`i` — not the handler signature param. When you also need pre-validate
parsing, the object form takes both arrays explicitly: `createFunction({
parse: [...], validate: [...] })(handler)`. The no-spec form
`createFunction()(handler)` exists too; it attaches the marker so the
dev-strict warning treats the export as deliberately unvalidated. Bound
captures (closure values from `.bind(...)` or render-time closures) are
explicitly *not* part of the validation contract — they're
integrity-protected by the AEAD action token from #421, not validated as
user inputs.
The full TypeScript story comes for free with this API. The handler's
parameter types are inferred from the schemas via the same
`ValidateSchema<T>` / `InferSchema<T>` machinery the typed router
already uses, so any Standard Schema (Zod, Valibot, ArkType, generic
`.parse()`) works as a slot constraint. Hovering an `addEntry` call site
that was declared with `z.object({ name: z.string() })` shows `(input: {
name: string }) => Promise<…>` — derived directly from the schema, no
manual type annotation. Misuse at the call site is a TypeScript error,
not a runtime surprise.
## Wire-aware helpers
A Standard Schema isn't enough for every Flight wire type. Some
validations need to bound resource consumption before the handler
observes the value (file uploads, byte buffers); some need to wrap an
async source so the bound is enforced as the handler consumes (streams,
async iterables); some need a constructor allowlist that's narrowed in
TypeScript via `instanceof` rather than a string-name lookup (typed
arrays). For each of those cases there's a dedicated wire-aware helper.
`formData(shape, options?)` declares a sub-FormData with declared-key
entries (no prefix scan, an attacker-injected `5_role=admin` is rejected
by default), and inside it `file({ maxBytes, mime })` and `blob(...)`
enforce per-entry size and MIME synchronously against `Blob.size` /
`Blob.type`. `arrayBuffer({ maxBytes })` caps byte length on `$AB`,
`typedArray({ ctor: Float32Array, maxBytes })` does the same for `$AT`
while narrowing the inferred handler type to the exact `Float32Array`
instance. `map({ maxSize, key, value })` and `set({ maxSize, value })`
cap collection size and route inner key/value validation through the
same Standard Schema bridge. `stream({ maxChunks, maxBytes })` covers
both the text (`$r`) and binary (`$b`) Flight stream tags by wrapping
the materialized `ReadableStream` in a `TransformStream` that errors
instead of yielding past the cap. `asyncIterable({ maxYields, value })`
and `iterable(...)` do the same for `$x` and `$X`, with each yielded
value flowing through the inner schema as the handler pulls.
`promise(value)` wraps `$@` so the resolved value runs through the
schema before reaching the handler. There's also a `noop` export — an
identity sentinel that reads as intent at the call site when only some
slots need validation, so users don't have to write sparse-array
literals or bare `undefined`.
## Decoder integration and error semantics
In `@lazarv/rsc`, `registerServerReference` gained an optional fourth
`meta` argument and a paired `lookupServerFunctionMeta` for hosts to
query at decode time. `decodeReply`'s options grew `actionId`,
`resolveServerFunctionMeta`, and `validateArg` hooks; when all three are
present the decoder switches from the legacy whole-tree walk to the new
slot-walk in `walkArgsWithMeta`, which applies parse → validate
slot-by-slot and aborts on the first failure with a new
`DecodeValidationError`. The error carries the failing `argIndex`, the
recovered `actionId`, a coarse `reason` code (`validate_failed`,
`parse_failed`, `unknown_entry`, `max_bytes_exceeded`,
`max_size_exceeded`, `max_chunks_exceeded`, `max_yields_exceeded`,
`mime_not_allowed`, `wire_shape_mismatch`, `missing_entry`,
`duplicate_entry`, `custom_validate_failed`, `max_bound_args_exceeded`),
and the underlying schema diagnostic in `original`. The legacy `$h` path
in `shared.mjs` got a parallel structural defense-in-depth pass: when an
action has registered meta and the encrypted token already delivered
bound captures, any non-empty wire-supplied `parsed.bound` is rejected
as a wire-shape mismatch — the trusted channel for closure captures is
the AEAD-protected token, not the wire's `bound` field.
## Dispatcher and dev guardrail
In `render-rsc.jsx`, the action-call dispatch now pre-resolves the
action id (header decrypt or `$ACTION_ID_*` form-field scan) *before*
`decodeReply`, then preloads the action's source module via
`requireModule` so the meta registry is populated by the time the
slot-walk asks for it. Without this preload, every action's first
invocation would silently skip validation because the registry is filled
by the module's top-level `registerServerReference` calls, which run
only after import. Validation failures map to HTTP 400 with the reason
in `x-react-server-action-error`. Schema diagnostics deliberately don't
travel to the client — they can leak expected-shape details that aid
attackers — but they're written to the server log via `logger.warn` for
operator visibility. There's also a dev-only guardrail: each unwrapped
`"use server"` action logs a one-time warning the first time it's
called, naming the action in the same `<modulePath>#<exportName>` form
the registry keys on, in a styled message that distinguishes file paths
(gray italic) from JS code (magenta) and import specifiers (cyan). Set
`config.serverFunctions.strict = false` to silence it during incremental
migrations.
Summary
Two independent, config-driven feature gates that let an application turn off entire request-handling pipelines it doesn't use. Setting
serverFunctions: falseshort-circuits the action-dispatch block inrender-rsc.jsx; settingremoteComponents: falseshort-circuits Remote Components rendering, the body-as-remote-props decode path, and the temporary-reference set. In both cases the runtime falls through to normal page rendering — an attacker can still POST at the endpoint, but the runtime behaves as if it were a static site.The motivation is defense-in-depth, not just code-size. Today, even an app with zero Server Functions still parses incoming
POST/PUT/PATCH/DELETEbodies, runs AES-GCM decrypt attempts, and walks the manifest before producingServerFunctionNotFoundError. That's a probe surface. With these gates closed, none of that work happens — no body drain, no decrypt, no manifest lookup, no proxy allocation, no decode walk over attacker-controlled JSON.What's new
config.serverFunctionsnow accepts the literalfalsein addition to its existing object shape. The runtime applies the same gate automatically when running a production build whoseserverReferenceMapwas replaced with a literal empty object bylib/plugins/server-reference-map.mjs:writeBundle— apps that genuinely have no"use server"modules and no inline Server Functions don't need to opt in. Dev mode always assumes Server Functions might exist (the dev manifest is a lazy Proxy that fabricates entries on demand, so emptiness isn't a reliable signal), and devs are iterating anyway.config.remoteComponentsis a new top-level key whose only meaningful value isfalse. There's no manifest to detect emptiness against, so opting out is a deliberate choice. When set, the runtime ignores the@__react_server_remote__URL marker, skips the body-as-remote-props read, never invokesdecodeReplyon the request body for prop hydration, and never creates the temporary-reference set. Temporary references are gated by the same flag because their only legitimate use is round-tripping non-serializable client values back to the same client during a Remote Components render — outside that, any incoming$Ttag is malformed and rejected.