Skip to content

feat: server functions auto-disable#422

Merged
lazarv merged 1 commit into
mainfrom
feat/server-functions-auto-disable
May 8, 2026
Merged

feat: server functions auto-disable#422
lazarv merged 1 commit into
mainfrom
feat/server-functions-auto-disable

Conversation

@lazarv
Copy link
Copy Markdown
Owner

@lazarv lazarv commented May 8, 2026

Summary

Two independent, config-driven feature gates that let an application turn off entire request-handling pipelines it doesn't use. Setting serverFunctions: false short-circuits the action-dispatch block in render-rsc.jsx; setting remoteComponents: false short-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 / DELETE bodies, runs AES-GCM decrypt attempts, and walks the manifest before producing ServerFunctionNotFoundError. 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.serverFunctions now accepts the literal false in addition to its existing object shape. The runtime applies the same gate automatically when running a production build whose serverReferenceMap was replaced with a literal empty object by lib/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.remoteComponents is a new top-level key whose only meaningful value is false. 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 invokes decodeReply on 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 $T tag is malformed and rejected.

@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 944783f May 08 2026, 09:54 PM

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

⚡ Flight Protocol Benchmark

Commit: 6432935

Serialization (renderToReadableStream)

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.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 8, 2026

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
1055 1 1054 6
View the top 3 failed test(s) by shortest run time
__test__/deno.spec.mjs > create-react-server: deno runtime (pnpm) > preset: nextjs > starts in production mode
Stack Traces | 0.000849s run time
AssertionError: production start should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:74:66
__test__/deno.spec.mjs > create-react-server: deno runtime (pnpm) > preset: nextjs > builds the app
Stack Traces | 0.00774s run time
AssertionError: build should succeed: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:70:58
__test__/scroll-restoration.spec.mjs > scroll restoration: multiple back/forward preserves positions
Stack Traces | 27.8s run time
AssertionError: expected 0 to be greater than 400
 ❯ __test__/scroll-restoration.spec.mjs:158:25

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

⚡ Benchmark Results

PR 944783f main 64ce19d
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1439 ⚪ +0.1% 34.17 ms ⚪ 0.0% 68 ms 0.9 MB/s
small 1462 🔴 -3.3% 33.54 ms 🔴 +3.2% 62 ms 1.4 MB/s
medium 383 🔴 -3.0% 128.39 ms 🔴 +2.9% 176 ms 5.6 MB/s
large 48 🟢 +6.3% 1016.62 ms 🟢 -4.1% 1956 ms 4.8 MB/s
deep 909 🔴 -5.4% 54.25 ms 🔴 +5.8% 88 ms 3.1 MB/s
wide 64 🔴 -9.4% 741.16 ms 🔴 +7.4% 1361 ms 3.5 MB/s
cached 3922 🟢 +15.1% 12.2 ms 🟢 -13.5% 25 ms 57.6 MB/s
client-min 533 ⚪ +0.8% 92.67 ms ⚪ -0.7% 146 ms 2.3 MB/s
client-small 553 🟢 +1.5% 89.39 ms 🟢 -1.1% 134 ms 2.5 MB/s
client-med 388 🟢 +3.3% 127.05 ms 🟢 -2.8% 198 ms 7.1 MB/s
client-large 73 🔴 -8.1% 645.67 ms 🔴 +7.8% 1262 ms 7.6 MB/s
client-deep 489 🔴 -2.9% 100.64 ms 🔴 +2.3% 156 ms 3.4 MB/s
client-wide 129 🔴 -6.0% 377.33 ms 🔴 +4.4% 667 ms 7.5 MB/s
rsc-client-large 1190 ⚪ -1.0% 41.35 ms ⚪ +1.0% 64 ms 3.0 MB/s
rsc-client-wide 1178 🔴 -3.3% 41.81 ms 🔴 +3.5% 61 ms 3.0 MB/s
static-json 10713 ⚪ -0.8% 4.22 ms 🔴 +6.8% 13 ms 4.5 MB/s
static-js 10365 🔴 -5.6% 4.48 ms 🔴 +12.8% 14 ms 13.0 MB/s
404-miss 6198 🟢 +1.6% 7.57 ms 🟢 -2.4% 16 ms 0.8 MB/s
hybrid-min 514 🔴 -5.7% 96.18 ms 🔴 +6.2% 152 ms 2.4 MB/s
hybrid-small 485 🔴 -6.0% 101.7 ms 🔴 +6.3% 153 ms 2.8 MB/s
hybrid-medium 242 🔴 -2.4% 203.11 ms 🔴 +2.2% 295 ms 10.3 MB/s
hybrid-large 43 🟢 +9.5% 1153.12 ms 🟢 -2.0% 2005 ms 13.8 MB/s
hybrid-deep 385 🔴 -2.3% 127.37 ms 🔴 +1.5% 184 ms 5.3 MB/s
hybrid-wide 58 ⚪ +0.4% 814.07 ms ⚪ +0.2% 1290 ms 11.4 MB/s
hybrid-cached 3111 🟢 +10.3% 15.53 ms 🟢 -9.7% 29 ms 132.1 MB/s
hybrid-client-min 555 🔴 -2.4% 89 ms 🔴 +2.3% 144 ms 2.4 MB/s
hybrid-client-small 530 🔴 -5.9% 93.21 ms 🔴 +6.1% 141 ms 2.5 MB/s
hybrid-client-medium 377 🔴 -3.0% 129.94 ms 🔴 +1.9% 187 ms 7.0 MB/s
hybrid-client-large 75 🔴 -5.8% 650.44 ms 🔴 +7.9% 1305 ms 7.9 MB/s
hybrid-client-deep 470 🔴 -5.1% 105.12 ms 🔴 +5.0% 156 ms 3.4 MB/s
hybrid-client-wide 125 🔴 -7.0% 390.38 ms 🔴 +6.5% 706 ms 7.3 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.

@lazarv lazarv merged commit be0ca75 into main May 8, 2026
153 of 156 checks passed
@lazarv lazarv deleted the feat/server-functions-auto-disable branch May 8, 2026 22:23
lazarv added a commit that referenced this pull request 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.
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