feat: server functions auto-disable#422
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
react-server-docs | 944783f | May 08 2026, 09:54 PM |
⚡ Flight Protocol BenchmarkCommit: Serialization (
|
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 300.6K | 41.1K | 🟢 +632.0% |
| react: shallow wide (1000) | 2.8K | 377 | 🟢 +647.1% |
| react: deep nested (100) | 21.9K | 7.0K | 🟢 +212.9% |
| react: product list (50) | 8.3K | 2.6K | 🟢 +225.3% |
| react: large table (500x10) | 364 | 110 | 🟢 +231.9% |
| data: primitives | 226.1K | 48.6K | 🟢 +365.5% |
| data: large string (100KB) | 9.3K | 8.6K | 🟢 +8.8% |
| data: nested objects (20) | 78.8K | 34.1K | 🟢 +130.8% |
| data: large array (10K) | 154 | 154 | ⚪ +0.2% |
| data: Map & Set | 15.4K | 7.9K | 🟢 +95.4% |
| data: Date/BigInt/Symbol | 214.4K | 52.7K | 🟢 +307.2% |
| data: typed arrays | 48.2K | 17.3K | 🟢 +178.7% |
| data: mixed payload | 12.0K | 5.3K | 🟢 +126.5% |
Prerender (prerender)
| Scenario | @lazarv/rsc ops/s | mean |
|---|---|---|
| react: minimal element | 313.8K | 3.2 µs |
| react: shallow wide (1000) | 2.6K | 383.2 µs |
| react: deep nested (100) | 20.4K | 49.1 µs |
| react: product list (50) | 7.9K | 126.4 µs |
| react: large table (500x10) | 341 | 2.93 ms |
| data: primitives | 241.8K | 4.1 µs |
| data: large string (100KB) | 856 | 1.17 ms |
| data: nested objects (20) | 79.2K | 12.6 µs |
| data: large array (10K) | 154 | 6.51 ms |
| data: Map & Set | 15.8K | 63.4 µs |
| data: Date/BigInt/Symbol | 236.3K | 4.2 µs |
| data: typed arrays | 855 | 1.17 ms |
| data: mixed payload | 10.8K | 92.8 µs |
Deserialization (createFromReadableStream)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 221.9K | 181.6K | 🟢 +22.2% |
| react: shallow wide (1000) | 29.6K | 2.7K | 🟢 +998.0% |
| react: deep nested (100) | 123.8K | 25.7K | 🟢 +381.5% |
| react: product list (50) | 63.0K | 18.6K | 🟢 +238.2% |
| react: large table (500x10) | 4.9K | 2.7K | 🟢 +83.3% |
| data: primitives | 177.1K | 174.8K | 🟢 +1.3% |
| data: large string (100KB) | 50.0K | 42.8K | 🟢 +16.6% |
| data: nested objects (20) | 103.3K | 92.4K | 🟢 +11.8% |
| data: large array (10K) | 363 | 330 | 🟢 +9.9% |
| data: Map & Set | 21.4K | 19.3K | 🟢 +11.1% |
| data: Date/BigInt/Symbol | 179.1K | 156.8K | 🟢 +14.2% |
| data: typed arrays | 72.3K | 57.7K | 🟢 +25.3% |
| data: mixed payload | 32.2K | 19.6K | 🟢 +64.2% |
Roundtrip (serialize + deserialize)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 159.6K | 37.6K | 🟢 +325.0% |
| react: shallow wide (1000) | 2.4K | 384 | 🟢 +521.0% |
| react: deep nested (100) | 18.8K | 6.0K | 🟢 +212.3% |
| react: product list (50) | 7.4K | 2.2K | 🟢 +230.7% |
| react: large table (500x10) | 328 | 103 | 🟢 +218.6% |
| data: primitives | 120.0K | 49.8K | 🟢 +141.0% |
| data: large string (100KB) | 8.1K | 8.9K | 🔴 -8.5% |
| data: nested objects (20) | 49.1K | 29.5K | 🟢 +66.2% |
| data: large array (10K) | 109 | 100 | 🟢 +8.8% |
| data: Map & Set | 8.9K | 5.6K | 🟢 +59.0% |
| data: Date/BigInt/Symbol | 115.0K | 39.8K | 🟢 +188.9% |
| data: typed arrays | 39.2K | 14.7K | 🟢 +167.3% |
| data: mixed payload | 8.6K | 4.3K | 🟢 +101.5% |
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.
❌ 1 Tests Failed:
View the top 3 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
⚡ 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. |
## 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 This PR extends the server-function security work with the *transport-layer* defences that wrap around the per-arg validation that landed in #424. #421 hardened the action token, #422 added the kill-switch for apps with no `"use server"` exports, #424 gave the decoder a per-slot validation contract. What was still missing — and what this lands — is a set of defences that fire *before* a request ever reaches the decoder: a raw body size cap, per-part multipart caps for the FormData / file-upload shape, and Origin-based CSRF rejection for the one action-call shape that CORS doesn't already cover. None of these defences are reachable from `createFunction` itself; they sit on the HTTP middleware and run regardless of whether an action opted into validation. ## Body size cap `server.maxBodyBytes` is a pre-parse cap on the raw request body, enforced before the WHATWG `Request` is constructed and applied to every body-bearing `POST` / `PUT` / `PATCH` / `DELETE` regardless of route or content-type. It defaults to `0` (disabled) — most production deployments terminate body limits at a CDN / proxy / platform edge, and a second runtime-level cap doesn't add defence in depth in that topology. Set it to a positive value when running without a proxy in front (single-host deployments, local-only services) or as a belt-and-braces alongside an upstream limit. Two paths handle the cap. When the client sends a `Content-Length` greater than the cap, the server responds `413 Payload Too Large` immediately and reads zero body bytes — the cheap path for honest clients with a declared length. When `Content-Length` is missing or lying (chunked transfer, attacker-controlled headers), bytes are counted as they arrive through a wrapping `Transform`; on overflow the underlying socket is destroyed immediately to bound resource usage. The connection close surfaces on the client side as a socket-level error rather than a 413 — the deliberate trade for not draining the rest of an attacker-controlled payload just to deliver a courtesy status code. Memory peak is bounded by the wrapping stream's `highWaterMark` (~16 KiB) regardless of the rejected payload's declared size; time is bounded by the HTTP server's `requestTimeout`. The cap is independent of, and runs before, the per-decode limits in `serverFunctions.limits.*` — those still apply afterwards inside the decoder. ## Multipart per-part caps `server.maxBodyBytes` bounds total wire bytes but cannot defend against attacks that fit inside any reasonable body cap: 1M small fields × 32 bytes each is only ~32 MiB on the wire but allocates 1M `FormData` entries plus per-entry strings; a single field with a 1 MiB *name* has small wire bytes but allocates a 1 MiB string in the parser; a large blob without `filename=` is treated as a string field by the platform parser, bypassing any downstream `file()` size policy. `server.multipart.*` adds per-part caps that fire during the streaming parse rather than after materialisation: `maxFileSize`, `maxFieldSize`, `maxFiles`, `maxFields`, `maxParts`, `maxFieldNameSize`. Overflow on any limit rejects with HTTP 413 *before* the offending part is fully buffered. The implementation lives in `lib/http/multipart-cap.mjs` and switches the parser based on configuration. When *any* sub-limit is set to a positive value, multipart bodies are parsed via `busboy` (configured with `defParamCharset: "utf8"` to match `undici`'s `Request.formData()` behaviour around filename encoding); when every sub-limit is disabled, busboy is never invoked and bodies pass through to the platform parser unchanged — zero overhead. The parsed `FormData` is functionally equivalent to what the platform parser would produce (filename, MIME, size, bytes preserved); only `Content-Transfer-Encoding` per part diverges, and since the HTML5 spec dropped it for `multipart/form-data` and modern browsers never emit it, this affects nothing in practice. An A/B equivalence test in `http-multipart-cap.spec.mjs` asserts the property. The cap only applies on the Node `createMiddleware` path — edge / serverless adapters have their own platform-level multipart limits and are not affected. ## CSRF / Origin validation The threat surface for CSRF on server functions is narrower than it first looks. JS-driven action calls — `fetch()` with the custom `react-server-action` header — are already safe: the custom header makes the request not CORS-simple, so the browser preflights it and the runtime refuses unsolicited cross-origin preflights. What needs explicit defence is the **form-submit shape**: `<form method="POST">` with a `multipart/form-data` body and a `$ACTION_ID_<token>` field. That shape is CORS-simple — browsers send it without preflight — so a malicious site can submit such a form cross-origin unless the receiving app validates the source. `server.csrf` validates the request's `Origin` (or `Referer`) against a trusted-origin set. The set is built implicitly from existing config: the request's own resolved origin (proxy-aware) so same-origin posts always work without configuration, plus `server.origin`, plus `server.cors.origin`/`origins` when configured with explicit values (CORS-trusted partners are usually CSRF-trusted too), plus `server.csrf.allowedOrigins` for cases where CSRF trust differs from CORS trust. Mode `"lax"` (the default) allows missing-Origin requests (some non-browser clients and older proxies don't send it); `"strict"` rejects them; `false`/`"off"` disables the check entirely. Rejections return HTTP `403 Forbidden` with `x-react-server-action-error: csrf_origin_mismatch` (or `csrf_origin_missing` in strict mode); the handler never runs and the body is never parsed. The case that actually needs explicit configuration is **remote components**. When a host app embeds remote components from another app, the user's browser sees forms whose action targets the remote, so on submit the browser POSTs cross-origin to the remote with `Origin: <host origin>`. Without an entry in `server.csrf.allowedOrigins`, the remote rejects the legitimate form submit with 403 — by design, since the remote operator must explicitly declare which host origins may invoke their action endpoints. The `examples/remote` runtime config has been updated with the canonical pattern (local-dev hosts pre-populated, production hosts as commented-out template) and inline rationale so adopters don't have to reverse-engineer the policy. Token-based CSRF (double-submit cookie / per-session nonce) is deliberately out of scope here — it requires session awareness the runtime can't synthesise on the app's behalf, and apps that need it can layer it as middleware in front of the action-dispatch. Wiring lives in `render-rsc.jsx`: the action-dispatch block detects the form-submit shape (multipart body, no `react-server-action` header) and calls `checkCsrf(context.request, config)` *before* `decodeReply` runs. A rejection throws `CsrfRejectedError`, which is caught alongside the existing `DecodeValidationError` branch and mapped to 403 with a warn-level server log. Origin details are deliberately not echoed in the response body; clients only need the reason header. The same catch block also fixes a latent bug in the `DecodeValidationError` path: the prior `if (getContext(HTTP_HEADERS)) set` shape silently dropped the error header when the context hadn't been initialised yet, which could happen when the catch fired before any other code path had touched `HTTP_HEADERS`. The new code mirrors the canonical setter pattern from `server/http-headers.mjs` — create-on-demand and write back via `context$`. ## File-upload integration `createFunction`'s `formData()` / `file()` helpers shipped in #424 but the tests at the time covered them in isolation. This PR adds a full end-to-end spec (`test/__test__/file-upload.spec.mjs` plus the matching fixtures) that drives uploads through a real browser, the multipart wire, the WHATWG `Request`, the platform / busboy parser path, and finally the `createFunction` decode. Each action computes a SHA-256 of the bytes server-side and returns the digest; the spec recomputes the digest from the same byte source it sent and asserts equality, proving the bytes survived the full round-trip intact through every parser configuration. The fixture also exercises the multipart cap by configuring a generous-but-finite `maxFileSize` and verifying overflow rejects with 413 before the file fully buffers. ## Action-token decode perf hardening `decryptActionToken` runs on every action-shaped `POST`, and under sustained attacker traffic AES-GCM auth-tag verification (even when it fails) is several orders of magnitude more expensive than a charset or length check. Two cheap pre-filters were added: minimum encoded length (38 chars, the structural minimum for a base64url-encoded 12-byte IV + 16-byte auth tag) and a base64url charset regex. Garbage tokens now bail in microseconds without any base64 decode or AES setup. The base64 decode itself was also hoisted out of the per-key loop — without that hoist, the decode ran N times for N rotation keys on every request, wasted work that grew linearly with rotation depth. The bounds match the wire format's absolute structural minimum so the pre-filter never rejects a token the cipher itself would accept.
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.