Skip to content

feat: server functions validation#424

Merged
lazarv merged 1 commit into
mainfrom
feat/server-functions-validation
May 9, 2026
Merged

feat: server functions validation#424
lazarv merged 1 commit into
mainfrom
feat/server-functions-validation

Conversation

@lazarv
Copy link
Copy Markdown
Owner

@lazarv lazarv commented May 9, 2026

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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
react-server-docs 172d026 May 09 2026, 03:45 PM

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

⚡ Flight Protocol Benchmark

Commit: fca2cf4

Serialization (renderToReadableStream)

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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

⚡ Benchmark Results

PR 172d026 main be0ca75
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1800 🟢 +48.3% 27.2 ms 🟢 -33.0% 57 ms 1.2 MB/s
small 1859 🟢 +41.6% 26.3 ms 🟢 -29.8% 49 ms 1.8 MB/s
medium 507 🟢 +30.1% 97.01 ms 🟢 -23.2% 132 ms 7.5 MB/s
large 60 🟢 +29.3% 794.12 ms 🟢 -18.4% 1414 ms 6.1 MB/s
deep 1154 🟢 +29.2% 42.7 ms 🟢 -22.4% 72 ms 3.9 MB/s
wide 91 🟢 +26.7% 528.71 ms 🟢 -21.6% 963 ms 5.0 MB/s
cached 4509 🟢 +34.8% 10.53 ms 🟢 -27.0% 20 ms 66.2 MB/s
client-min 696 🟢 +50.5% 71.03 ms 🟢 -33.5% 112 ms 2.9 MB/s
client-small 680 🟢 +44.0% 72.62 ms 🟢 -30.6% 107 ms 3.1 MB/s
client-med 503 🟢 +40.9% 98.18 ms 🟢 -28.8% 145 ms 9.2 MB/s
client-large 112 🟢 +37.6% 441.54 ms 🟢 -25.3% 832 ms 11.8 MB/s
client-deep 650 🟢 +44.6% 75.93 ms 🟢 -30.9% 116 ms 4.6 MB/s
client-wide 185 🟢 +27.6% 264.53 ms 🟢 -21.8% 435 ms 10.8 MB/s
rsc-client-large 1578 🟢 +47.8% 31.08 ms 🟢 -32.7% 46 ms 4.0 MB/s
rsc-client-wide 1613 🟢 +50.0% 30.4 ms 🟢 -33.7% 46 ms 4.1 MB/s
static-json 12769 🟢 +78.1% 3.53 ms 🟢 -45.7% 12 ms 5.3 MB/s
static-js 12447 🟢 +80.1% 3.56 ms 🟢 -47.2% 12 ms 15.6 MB/s
404-miss 6617 🟢 +38.6% 7.06 ms 🟢 -28.9% 16 ms 0.8 MB/s
hybrid-min 680 🟢 +50.3% 72.8 ms 🟢 -33.1% 116 ms 3.2 MB/s
hybrid-small 666 🟢 +55.2% 74.26 ms 🟢 -35.5% 113 ms 3.9 MB/s
hybrid-medium 325 🟢 +41.0% 150.79 ms 🟢 -29.7% 218 ms 13.8 MB/s
hybrid-large 56 🟢 +25.9% 877.31 ms 🟢 -19.8% 1664 ms 17.9 MB/s
hybrid-deep 515 🟢 +49.3% 95.79 ms 🟢 -32.8% 134 ms 7.1 MB/s
hybrid-wide 81 🟢 +38.9% 611.72 ms 🟢 -23.9% 1047 ms 15.9 MB/s
hybrid-cached 3803 🟢 +31.9% 12.65 ms 🟢 -24.7% 24 ms 161.5 MB/s
hybrid-client-min 722 🟢 +52.9% 68.47 ms 🟢 -34.7% 104 ms 3.2 MB/s
hybrid-client-small 709 🟢 +52.7% 69.7 ms 🟢 -34.3% 107 ms 3.4 MB/s
hybrid-client-medium 501 🟢 +46.7% 98.63 ms 🟢 -31.7% 145 ms 9.3 MB/s
hybrid-client-large 113 🟢 +41.0% 431.36 ms 🟢 -29.3% 825 ms 11.9 MB/s
hybrid-client-deep 631 🟢 +49.5% 78.18 ms 🟢 -33.3% 120 ms 4.5 MB/s
hybrid-client-wide 189 🟢 +48.0% 259.83 ms 🟢 -31.9% 420 ms 11.0 MB/s
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-commenter
Copy link
Copy Markdown

codecov-commenter commented May 9, 2026

Codecov Report

❌ Patch coverage is 85.15152% with 49 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@be0ca75). Learn more about missing BASE report.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/rsc/server/reply-decoder.mjs 85.48% 45 Missing ⚠️
packages/rsc/server/shared.mjs 80.00% 4 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #424   +/-   ##
=======================================
  Coverage        ?   91.41%           
=======================================
  Files           ?        3           
  Lines           ?     3948           
  Branches        ?     1323           
=======================================
  Hits            ?     3609           
  Misses          ?      339           
  Partials        ?        0           
Flag Coverage Δ
rsc 91.41% <85.15%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lazarv lazarv merged commit a345961 into main May 9, 2026
63 checks passed
@lazarv lazarv deleted the feat/server-functions-validation branch May 9, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants