feat: server functions validation#424
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
react-server-docs | 172d026 | May 09 2026, 03:45 PM |
⚡ Flight Protocol BenchmarkCommit: Serialization (
|
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 222.9K | 26.1K | 🟢 +753.0% |
| react: shallow wide (1000) | 2.2K | 331 | 🟢 +564.0% |
| react: deep nested (100) | 17.3K | 5.8K | 🟢 +198.9% |
| react: product list (50) | 6.2K | 2.0K | 🟢 +212.4% |
| react: large table (500x10) | 284 | 89 | 🟢 +219.2% |
| data: primitives | 176.5K | 35.9K | 🟢 +391.9% |
| data: large string (100KB) | 7.1K | 7.3K | 🔴 -1.6% |
| data: nested objects (20) | 57.2K | 25.9K | 🟢 +120.6% |
| data: large array (10K) | 114 | 108 | 🟢 +6.1% |
| data: Map & Set | 10.7K | 5.8K | 🟢 +83.3% |
| data: Date/BigInt/Symbol | 166.0K | 36.9K | 🟢 +350.3% |
| data: typed arrays | 33.7K | 12.9K | 🟢 +160.7% |
| data: mixed payload | 8.0K | 4.1K | 🟢 +97.4% |
Prerender (prerender)
| Scenario | @lazarv/rsc ops/s | mean |
|---|---|---|
| react: minimal element | 260.6K | 3.8 µs |
| react: shallow wide (1000) | 2.0K | 489.5 µs |
| react: deep nested (100) | 16.2K | 61.7 µs |
| react: product list (50) | 5.9K | 170.5 µs |
| react: large table (500x10) | 274 | 3.64 ms |
| data: primitives | 193.8K | 5.2 µs |
| data: large string (100KB) | 691 | 1.45 ms |
| data: nested objects (20) | 58.1K | 17.2 µs |
| data: large array (10K) | 116 | 8.59 ms |
| data: Map & Set | 11.1K | 90.0 µs |
| data: Date/BigInt/Symbol | 183.7K | 5.4 µs |
| data: typed arrays | 666 | 1.50 ms |
| data: mixed payload | 7.6K | 132.2 µs |
Deserialization (createFromReadableStream)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 170.0K | 137.7K | 🟢 +23.5% |
| react: shallow wide (1000) | 24.0K | 2.0K | 🟢 +1113.4% |
| react: deep nested (100) | 102.6K | 19.4K | 🟢 +430.1% |
| react: product list (50) | 53.1K | 14.5K | 🟢 +266.3% |
| react: large table (500x10) | 4.2K | 2.1K | 🟢 +97.0% |
| data: primitives | 138.8K | 128.7K | 🟢 +7.9% |
| data: large string (100KB) | 40.8K | 34.2K | 🟢 +19.2% |
| data: nested objects (20) | 84.1K | 70.7K | 🟢 +18.9% |
| data: large array (10K) | 279 | 249 | 🟢 +12.0% |
| data: Map & Set | 16.6K | 14.5K | 🟢 +14.4% |
| data: Date/BigInt/Symbol | 136.9K | 110.7K | 🟢 +23.7% |
| data: typed arrays | 56.5K | 41.9K | 🟢 +34.7% |
| data: mixed payload | 25.5K | 14.8K | 🟢 +72.3% |
Roundtrip (serialize + deserialize)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 104.4K | 21.6K | 🟢 +382.7% |
| react: shallow wide (1000) | 1.7K | 279 | 🟢 +520.1% |
| react: deep nested (100) | 14.2K | 4.2K | 🟢 +235.7% |
| react: product list (50) | 5.3K | 1.4K | 🟢 +272.7% |
| react: large table (500x10) | 261 | 82 | 🟢 +216.9% |
| data: primitives | 80.4K | 25.1K | 🟢 +219.7% |
| data: large string (100KB) | 6.5K | 6.6K | 🔴 -1.6% |
| data: nested objects (20) | 34.3K | 18.2K | 🟢 +88.2% |
| data: large array (10K) | 83 | 79 | 🟢 +5.5% |
| data: Map & Set | 6.3K | 4.0K | 🟢 +56.4% |
| data: Date/BigInt/Symbol | 70.4K | 24.5K | 🟢 +187.6% |
| data: typed arrays | 24.8K | 10.9K | 🟢 +126.8% |
| data: mixed payload | 6.0K | 3.0K | 🟢 +100.8% |
Legend & methodology
Indicators: 🟢 > 1% faster | 🔴 > 1% slower | ⚪ within noise margin
vs webpack: compares @lazarv/rsc against react-server-dom-webpack within the same run.
vs baseline: compares @lazarv/rsc against the previous main branch run.
Values shown are operations/second (higher is better). Each scenario runs for at least 100 iterations with warmup.
Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple scenarios are more meaningful than any single number.
⚡ Benchmark Results
Legend🟢 > 1% improvement | 🔴 > 1% regression | ⚪ within noise margin Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple routes are more meaningful than any single number. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #424 +/- ##
=======================================
Coverage ? 91.41%
=======================================
Files ? 3
Lines ? 3948
Branches ? 1323
=======================================
Hits ? 3609
Misses ? 339
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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/functionsubpath. It wraps a"use server"handler with a per-arg parse/validate spec, the bundler forwards that spec toregisterServerReference, and the protocol decoder consults it on every call. Bad inputs are caught during decode and the request fails withHTTP 400and anx-react-server-action-error: <reason>header before any handler code runs. Bare"use server"actions withoutcreateFunctionkeep 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 positioni— 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 formcreateFunction()(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 anaddEntrycall site that was declared withz.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
instanceofrather 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-injected5_role=adminis rejected by default), and inside itfile({ maxBytes, mime })andblob(...)enforce per-entry size and MIME synchronously againstBlob.size/Blob.type.arrayBuffer({ maxBytes })caps byte length on$AB,typedArray({ ctor: Float32Array, maxBytes })does the same for$ATwhile narrowing the inferred handler type to the exactFloat32Arrayinstance.map({ maxSize, key, value })andset({ 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 materializedReadableStreamin aTransformStreamthat errors instead of yielding past the cap.asyncIterable({ maxYields, value })anditerable(...)do the same for$xand$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 anoopexport — 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 bareundefined.Decoder integration and error semantics
In
@lazarv/rsc,registerServerReferencegained an optional fourthmetaargument and a pairedlookupServerFunctionMetafor hosts to query at decode time.decodeReply's options grewactionId,resolveServerFunctionMeta, andvalidateArghooks; when all three are present the decoder switches from the legacy whole-tree walk to the new slot-walk inwalkArgsWithMeta, which applies parse → validate slot-by-slot and aborts on the first failure with a newDecodeValidationError. The error carries the failingargIndex, the recoveredactionId, a coarsereasoncode (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 inoriginal. The legacy$hpath inshared.mjsgot 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-suppliedparsed.boundis rejected as a wire-shape mismatch — the trusted channel for closure captures is the AEAD-protected token, not the wire'sboundfield.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) beforedecodeReply, then preloads the action's source module viarequireModuleso 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-levelregisterServerReferencecalls, which run only after import. Validation failures map to HTTP 400 with the reason inx-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 vialogger.warnfor 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). Setconfig.serverFunctions.strict = falseto silence it during incremental migrations.