feat: server function encrypted bound args#421
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
react-server-docs | aeba5fa | May 08 2026, 07:04 PM |
⚡ Flight Protocol BenchmarkCommit: Serialization (
|
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 227.9K | 34.8K | 🟢 +554.2% |
| react: shallow wide (1000) | 2.3K | 296 | 🟢 +668.4% |
| react: deep nested (100) | 17.1K | 5.4K | 🟢 +217.1% |
| react: product list (50) | 6.7K | 1.9K | 🟢 +250.3% |
| react: large table (500x10) | 279 | 97 | 🟢 +187.5% |
| data: primitives | 174.5K | 37.7K | 🟢 +363.3% |
| data: large string (100KB) | 7.2K | 6.4K | 🟢 +13.2% |
| data: nested objects (20) | 60.3K | 27.6K | 🟢 +118.2% |
| data: large array (10K) | 127 | 126 | ⚪ +0.6% |
| data: Map & Set | 12.0K | 6.5K | 🟢 +84.1% |
| data: Date/BigInt/Symbol | 169.4K | 40.3K | 🟢 +320.2% |
| data: typed arrays | 38.5K | 11.4K | 🟢 +239.1% |
| data: mixed payload | 9.3K | 3.9K | 🟢 +141.3% |
Prerender (prerender)
| Scenario | @lazarv/rsc ops/s | mean |
|---|---|---|
| react: minimal element | 227.3K | 4.4 µs |
| react: shallow wide (1000) | 2.0K | 501.6 µs |
| react: deep nested (100) | 15.8K | 63.5 µs |
| react: product list (50) | 6.2K | 162.2 µs |
| react: large table (500x10) | 267 | 3.74 ms |
| data: primitives | 189.2K | 5.3 µs |
| data: large string (100KB) | 668 | 1.50 ms |
| data: nested objects (20) | 61.3K | 16.3 µs |
| data: large array (10K) | 121 | 8.27 ms |
| data: Map & Set | 11.9K | 83.9 µs |
| data: Date/BigInt/Symbol | 184.6K | 5.4 µs |
| data: typed arrays | 663 | 1.51 ms |
| data: mixed payload | 8.2K | 122.5 µs |
Deserialization (createFromReadableStream)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 166.6K | 146.5K | 🟢 +13.7% |
| react: shallow wide (1000) | 22.1K | 2.1K | 🟢 +963.4% |
| react: deep nested (100) | 97.0K | 19.2K | 🟢 +405.8% |
| react: product list (50) | 49.6K | 14.7K | 🟢 +238.1% |
| react: large table (500x10) | 3.8K | 2.0K | 🟢 +89.7% |
| data: primitives | 134.9K | 132.0K | 🟢 +2.2% |
| data: large string (100KB) | 39.3K | 33.5K | 🟢 +17.5% |
| data: nested objects (20) | 79.5K | 68.8K | 🟢 +15.6% |
| data: large array (10K) | 270 | 238 | 🟢 +13.5% |
| data: Map & Set | 16.4K | 14.8K | 🟢 +11.0% |
| data: Date/BigInt/Symbol | 139.3K | 120.9K | 🟢 +15.2% |
| data: typed arrays | 54.6K | 43.6K | 🟢 +25.2% |
| data: mixed payload | 24.1K | 14.9K | 🟢 +62.2% |
Roundtrip (serialize + deserialize)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 119.6K | 29.0K | 🟢 +312.3% |
| react: shallow wide (1000) | 1.8K | 303 | 🟢 +480.8% |
| react: deep nested (100) | 15.0K | 4.7K | 🟢 +219.3% |
| react: product list (50) | 5.7K | 1.7K | 🟢 +237.6% |
| react: large table (500x10) | 252 | 89 | 🟢 +181.9% |
| data: primitives | 94.2K | 39.0K | 🟢 +141.3% |
| data: large string (100KB) | 6.6K | 6.8K | 🔴 -2.1% |
| data: nested objects (20) | 37.6K | 22.4K | 🟢 +67.8% |
| data: large array (10K) | 87 | 79 | 🟢 +10.5% |
| data: Map & Set | 6.9K | 4.4K | 🟢 +55.7% |
| data: Date/BigInt/Symbol | 93.8K | 31.2K | 🟢 +200.8% |
| data: typed arrays | 28.7K | 12.1K | 🟢 +136.2% |
| data: mixed payload | 6.8K | 3.3K | 🟢 +104.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 Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #421 +/- ##
=======================================
Coverage ? 91.85%
=======================================
Files ? 3
Lines ? 3622
Branches ? 1195
=======================================
Hits ? 3327
Misses ? 295
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:
|
⚡ 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
Server-emitted bound captures of server functions — the closure variables of inline
"use server"functions and the arguments passed to a server-side.bind(...)— used to travel plaintext on the wire as part of the$houtlined chunk'sboundarray. A malicious client could submit a legitimate action token paired with attacker-chosen bound values, swapping a captureduserId=42foruserId=99and updating a different user's data while authenticated as someone else. Classic IDOR / bound-arg tampering.This PR bundles every action's bound capture array into the same AES-256-GCM token that already protects action identity. Token plaintext becomes
[actionId, boundBytesAsBase64], where the bound bytes come from@lazarv/rsc's sync flight encoder. Bound values never travel plaintext on the wire, and any tampering — at the token, the action id, or the bound payload — invalidates the GCM auth tag and the call is rejected before the action runs.Why an AEAD primitive instead of a separate HMAC
The first attempt sat an HMAC tag alongside the plaintext bound on the wire, then bound
(id, bound, sig)together at verification time. That approach had a fundamental architectural flaw: by the time the user clicks a bound action,callServerpackages the bound prefix as positional args (not as a$hreference) so it lands in the call body indistinguishably from runtime args. There is no$hchunk in the call body to attach a sig to, and the dispatcher cannot tell which of the positional args were "bound" vs "user-supplied". Tampering would have been undetectable in the dominant code path.Encoding the bound array inside the encrypted action token closes this by removing the bound prefix from the wire entirely. The client sends only runtime args; the server recovers bound by decrypting the token and prepends it before invoking the action. There is nothing for an attacker to tamper with.
Type fidelity is the load-bearing detail
A naive implementation would
JSON.stringifythe bound array into the token plaintext. That silently strips type information fromDate,BigInt,Map,Set,RegExp,URL,URLSearchParams, typed arrays — every typed value the wire format already supports throughdecodeReply. After a round-trip, the action would receive astringwhere it expected aDate, etc. Bound captures are now routed throughsyncToBuffer/syncFromBuffer, the existing public sync flight serialization pair on@lazarv/rsc. Bound captures travel through the same$<tag>scheme thatdecodeReplyalready speaks for client-supplied args, so any typed value the framework supports anywhere else also survives bound-capture round-trip with full fidelity.Implementation
packages/react-server/server/action-crypto.mjsgainsencryptActionToken(actionId, bound)anddecryptActionToken(token). The encrypt path runsboundthroughsyncToBufferto get aUint8Array, base64-encodes it, and embeds it as the second element of the JSON plaintext[actionId, boundBytesAsBase64 | null]. The decrypt path inverts that: parse JSON, decode base64, runsyncFromBufferto recover the typed array.encryptActionIdbecomes a thin delegator overencryptActionToken(id, null)so existing callers keep working with the unified plaintext format.decryptActionIddelegates todecryptActionTokenand returns just the action id. A small fallback inparseTokenPlaintextaccepts pre-upgrade plain-string plaintexts as{ actionId, bound: null }so tokens issued before this change are still valid during a rolling deploy.packages/react-server/server/action-register.mjsupdatescreateServerRefBindso the cached$$idgetter returnsencryptActionToken(fullId, accumulatedBound)rather thanencryptActionId(fullId). The bound array is plaintext on the function (needed forFunction.prototype.bindinvocation and for progressive-enhancement form rendering) but only the encrypted token form goes onto the wire. The unboundregisterServerReferencepath still usesencryptActionId, which now produces a token whose plaintext is[fullId, null]— same shape, no special case at decrypt time.packages/react-server/server/render-rsc.jsxdoes three things. It exposes aresolveServerReferenceon the runtime'smoduleResolverthat returns{ id: ref.$$id, bound: null }for every server reference, so the flight serializer skips its plaintext-bound fallback. The header-based action dispatcher and the progressive-enhancement form-field dispatcher both calldecryptActionTokeninstead ofdecryptActionId, recover any token-encoded bound, and prepend it to the runtime args before invoking the action. ThedecodeReplywrapper passes adecryptServerReferenceIdhook into@lazarv/rscso the callback-arg case (a bound server reference passed as a value to another server function call) decrypts the inner token and prepends its bound at bind time.packages/rsc/server/shared.mjsandpackages/rsc/server/reply-decoder.mjsadd the host-supplied hooks. The flight serializer now honorsmetadata.boundfromresolveServerReferencewhen explicitly provided, falling back tovalue.$$boundonly when the resolver doesn't speak. The reply decoder accepts adecryptServerReferenceIdoption that, when present, transforms the$hchunk's id into{ actionId, bound }; the recovered bound is prepended to any wire-supplied bound array before binding. Both branches stay no-op by default —@lazarv/rscitself has no opinion about token formats and remains runtime-agnostic.Migration
Backward compatible. The encryption key resolution chain (
serverFunctions.secret/secretFile, env vars,previousSecrets/previousSecretFiles) is unchanged and covers both action identity and bound captures under one key. Tokens issued by an older runtime version that's still serving traffic during a rolling deploy decode cleanly via the legacy plain-string fallback inparseTokenPlaintext. There are no new configuration flags and no transitional period to manage.Tests
test/__test__/action-crypto.spec.mjscovers token roundtrip across primitive and structured bound values, tamper detection (single-byte flip, truncation, non-base64), key rotation (sign under previous, decrypt under primary or rotation), legacy plain-string plaintext compatibility,encryptActionId/decryptActionIdthin-wrapper semantics, and a full typed-value matrix assertingDate,BigInt,Map,Set,RegExp,URL,URLSearchParams, and typed arrays each survive the encrypt/decrypt round-trip with bothinstanceofand value equality. A nested-mix case asserts that typed values inside structured bound (aDateinside an object inside an array, aMap<string, Object[]>, etc.) all round-trip together.packages/rsc/__tests__/flight-bound-args-integrity.test.mjscovers the protocol layer: that a resolver returningbound: nulloverrides$$boundand emits no plaintext bound on the wire, that the unbound case carriesbound: nullend-to-end, that consumers without a resolver still get the legacy serialization (back-compat for plain@lazarv/rscusers), and that the$hdecoder hook is invoked on token-encoded ids in the callback-arg case and prepends recovered bound to any wire-supplied bound.The existing
test/__test__/use-inline.spec.mjs("use server inline with captured variables") exercises the full pipeline — page render → flight stream → client decode → callServer → decrypt → dispatch — with closures capturing render-time data. It is the load-bearing E2E for this change and continues to pass without modification.Docs
docs/src/pages/en/(pages)/guide/server-functions.mdxand the Japanese mirror gain a Security section covering action identity and bound captures, key resolution order, key rotation pattern, semantics of client-side.bind()extensions (treated as runtime args, not as new captures), and the one known limitation: bound captures whose values areFileorBlobcarry the slot reference in the token but not the binary content, which is rare in practice but worth flagging. The Japanese file also gets<Link name>anchors that match the EN convention and a closing fence for a previously dangling code block.