From 172d02600d59ff56b2cfff55adb94800355fa696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sat, 9 May 2026 17:43:03 +0200 Subject: [PATCH] feat: server functions validation --- .../en/(pages)/guide/server-functions.mdx | 232 +++++ .../ja/(pages)/guide/server-functions.mdx | 234 ++++- .../typed-file-router/pages/(root).layout.tsx | 2 + .../pages/guestbook.page.tsx | 64 ++ .../src/DeleteEntryButton.tsx | 48 + .../typed-file-router/src/GuestbookForm.tsx | 171 ++++ .../src/guestbook-actions.ts | 75 ++ examples/typed-router/App.tsx | 3 + examples/typed-router/DeleteEntryButton.tsx | 48 + examples/typed-router/Guestbook.tsx | 68 ++ examples/typed-router/GuestbookForm.tsx | 176 ++++ examples/typed-router/guestbook-actions.ts | 88 ++ examples/typed-router/router.tsx | 3 + examples/typed-router/routes.ts | 5 + packages/react-server/config/validate.mjs | 8 + .../react-server/lib/plugins/use-server.mjs | 44 + packages/react-server/package.json | 4 + .../react-server/server/action-register.mjs | 12 +- packages/react-server/server/function.d.ts | 568 ++++++++++++ packages/react-server/server/function.mjs | 525 +++++++++++ packages/react-server/server/render-rsc.jsx | 213 ++++- .../rsc/__tests__/flight-validation.test.mjs | 769 +++++++++++++++ packages/rsc/server/index.d.ts | 76 +- packages/rsc/server/index.mjs | 16 + packages/rsc/server/reply-decoder.mjs | 874 ++++++++++++++++++ packages/rsc/server/shared.mjs | 102 +- packages/rsc/types.d.ts | 62 ++ .../server-function-validation.spec.mjs | 378 ++++++++ .../server-function-validation-actions.mjs | 284 ++++++ .../server-function-validation-client.jsx | 222 +++++ test/fixtures/server-function-validation.jsx | 10 + 31 files changed, 5368 insertions(+), 16 deletions(-) create mode 100644 examples/typed-file-router/pages/guestbook.page.tsx create mode 100644 examples/typed-file-router/src/DeleteEntryButton.tsx create mode 100644 examples/typed-file-router/src/GuestbookForm.tsx create mode 100644 examples/typed-file-router/src/guestbook-actions.ts create mode 100644 examples/typed-router/DeleteEntryButton.tsx create mode 100644 examples/typed-router/Guestbook.tsx create mode 100644 examples/typed-router/GuestbookForm.tsx create mode 100644 examples/typed-router/guestbook-actions.ts create mode 100644 packages/react-server/server/function.d.ts create mode 100644 packages/react-server/server/function.mjs create mode 100644 packages/rsc/__tests__/flight-validation.test.mjs create mode 100644 test/__test__/server-function-validation.spec.mjs create mode 100644 test/fixtures/server-function-validation-actions.mjs create mode 100644 test/fixtures/server-function-validation-client.jsx create mode 100644 test/fixtures/server-function-validation.jsx diff --git a/docs/src/pages/en/(pages)/guide/server-functions.mdx b/docs/src/pages/en/(pages)/guide/server-functions.mdx index 576c4d48..797e4883 100644 --- a/docs/src/pages/en/(pages)/guide/server-functions.mdx +++ b/docs/src/pages/en/(pages)/guide/server-functions.mdx @@ -342,6 +342,238 @@ export default function TodoApp() { The file is treated as a client component because of the top-level `"use client"` directive, but the `addItem` function is extracted into a separate server module. This works the same way as defining the Server Function in a standalone `"use server"` file — the runtime handles the extraction automatically. + +## Validation + + +Server Functions accept arguments from the client. Any payload that flows from a browser into a server handler is, by definition, untrusted. The runtime offers an opt-in API — `createFunction` — that lets you declare a parse/validate contract for each runtime argument slot. The contract is enforced at the protocol layer: the decoder runs your `parse` and `validate` slot-by-slot during the args walk, and rejects the request on the first failure with HTTP 400 — *before* the handler runs, before any business logic touches the value, before any allocation that the handler might assume is bounded. + +Bare `"use server"` functions without `createFunction` keep working unchanged. The validation contract is opt-in and additive. + + +### `createFunction` + + +Wrap an action handler with `createFunction(spec)(handler)`. Pass the per-slot validate specs as an array — the most common shape: + +```jsx +import { + createFunction, + formData, + file, +} from "@lazarv/react-server/function"; +import { z } from "zod"; + +export const updateProfile = createFunction([ + z.string().email(), + z.string().min(2).max(80), +])(async function updateProfile(email, name) { + "use server"; + await db.users.update({ email, name }); +}); +``` + +Index `i` in the array describes the **runtime arg slot `i`** — what the client puts on the wire at position `i`. It is *not* the handler's signature parameter `i`. Bound captures from `.bind(...)` or inline closures are not part of this contract — they're hidden values, integrity-protected by the AEAD action token, and never validated as user input. + +The runtime accepts any [Standard Schema](https://standardschema.dev/) — Zod, Valibot, ArkType — duck-typed via `safeParse` / `assert` / `parse`. You don't import a particular library from `@lazarv/react-server`; bring whichever schema library your project already uses. + + +### Parse, then validate + + +When you also need pre-validate parsing, switch to the object form: `createFunction({ validate, parse })`. Both fields are arrays indexed by the same slot. `parse[i]` runs after the value tree is materialized but before `validate[i]` — the right place for type coercion that schemas can't easily express: + +```js +export const setLimit = createFunction({ + parse: [(v) => Number(v)], + validate: [z.number().int().min(1).max(1000)], +})(async function setLimit(limit) { + "use server"; + await db.config.set({ limit }); +}); +``` + +A throwing `parse` surfaces as `DecodeValidationError(reason: "parse_failed")`; a failing `validate` surfaces as `reason: "validate_failed"`. The decoder aborts on the first failure — the next slot is never touched, the handler is never called. + + +### Skipping slots with `noop` + + +When only some slots need validation or parsing, use the `noop` export as a placeholder rather than reaching for sparse-array syntax (`[, , schema]`) or explicit `undefined`: + +```js +import { createFunction, noop } from "@lazarv/react-server/function"; + +export const update = createFunction([noop, noop, z.number().int()])( + async function update(a, b, count) { + // a: unknown, b: unknown, count: number — slots 0/1 are accepted + // unvalidated; slot 2 is the only one constrained. + "use server"; + } +); +``` + +Same idea in the object form when `parse` and `validate` need different gap patterns: + +```js +createFunction({ + parse: [noop, noop, (v) => Number(v)], + validate: [z.string(), noop, z.number()], +})(handler); +// handler: (a: string, b: unknown, c: number) +``` + +`noop` is the identity transform — at runtime it's the same as omitting validation/parsing for that slot. Slots typed `noop` resolve to `unknown` in the inferred handler signature. + + +### `formData` — file uploads and structured forms + + +`FormData` arguments are common when uploading files or submitting progressive-enhancement forms. Schema libraries can describe object shapes, but they describe *materialized* values — by the time a schema runs, the file is already buffered. `formData` is wire-aware: the decoder enforces declared keys and per-entry constraints *during* the FormData walk, so size and MIME limits are checked synchronously against `Blob.size` / `Blob.type` before the entry is added to the result. + +```js +import { + createFunction, + formData, + file, +} from "@lazarv/react-server/function"; + +export const upload = createFunction([ + formData({ + title: z.string().min(1).max(120), + photo: file({ + maxBytes: 5 * 1024 * 1024, + mime: ["image/png", "image/jpeg"], + }), + }), +])(async function upload(form) { + "use server"; + const title = form.get("title"); + const photo = form.get("photo"); + // photo is already size- and MIME-checked. +}); +``` + +`formData(shape, options?)` takes the entry shape as its first argument and an optional config bag as its second. The shape is required; the only option today is `unknown`, but the slot is reserved for future per-form constraints. + +The decoder looks up declared entries by **exact key**. There is no prefix scan, so an attacker-injected entry like `5_role=admin` cannot land in the FormData your handler reads. + +The `unknown` policy — passed as `formData(shape, { unknown: "drop" })` — controls what happens to entries that are *not* declared: + +- `"reject"` (default, recommended): an unknown entry fails the decode with `DecodeValidationError(reason: "unknown_entry")`. Defends against attacker-injected fields. +- `"drop"`: silently skip undeclared entries. Useful when the form includes React-managed hidden fields the schema doesn't enumerate. +- `"allow"`: copy undeclared entries through unvalidated. Escape hatch — documented as unsafe. + +`file({...})` and `blob({...})` accept `maxBytes` (per-entry size cap), `mime` (allowlist of acceptable MIME types), an optional sync `validate(value)` callback for custom checks (e.g. magic-byte detection), and `optional: true` to allow a missing entry. `File.type` is browser-supplied and trivially spoofable — combine MIME with magic-byte detection in `validate` for hard guarantees. + + +### Other wire types + + +The Flight protocol carries more than just primitives, objects, and `FormData`. For each remaining wire type where a Standard Schema isn't enough on its own — usually because validation needs to bound resource consumption, type-check against a platform constructor, or wrap an async source — there's a dedicated wire-aware helper: + +```js +import { + createFunction, + arrayBuffer, + typedArray, + map, + set, + stream, + asyncIterable, + iterable, + promise, +} from "@lazarv/react-server/function"; +``` + +| Helper | Wire tag | Handler-side type | Why it's wire-aware | +|---|---|---|---| +| `arrayBuffer({ maxBytes })` | `$AB` | `ArrayBuffer` | Byte-length cap before the handler observes the buffer | +| `typedArray({ ctor, maxBytes })` | `$AT` | `InstanceType` (e.g. `Float32Array`) | Constructor allowlist via `instanceof`; type narrowed in inference | +| `map({ maxSize, key, value })` | `$Q` | `Map` | Size cap; inner key/value schemas | +| `set({ maxSize, value })` | `$W` | `Set` | Size cap; per-item schema | +| `stream({ maxChunks, maxBytes })` | `$r` / `$b` | `ReadableStream` | Bounds enforced as the handler consumes the stream | +| `asyncIterable({ maxYields, value })` | `$x` | `AsyncIterable` | Yield ceiling + per-yield validation | +| `iterable({ maxYields, value })` | `$X` | `Iterable` | Same, sync flavor | +| `promise(value)` | `$@` | `Promise` | Resolved-value validation through the schema | + +A few examples in context: + +```js +// Binary upload arriving as a typed array +export const upload = createFunction([ + typedArray({ ctor: Uint8Array, maxBytes: 5 * 1024 * 1024 }), +])(async function upload(bytes) { + "use server"; + // bytes: Uint8Array, already size-checked +}); + +// Bounded Map argument +export const lookup = createFunction([ + map({ maxSize: 100, key: z.string(), value: z.number() }), +])(async function lookup(table) { + "use server"; + // table: Map, capped at 100 entries +}); + +// Streaming upload with a per-stream byte cap +export const ingest = createFunction([ + stream({ maxBytes: 50 * 1024 * 1024, maxChunks: 8192 }), +])(async function ingest(stream) { + "use server"; + // stream: ReadableStream — wrapped to error if either ceiling is exceeded + for await (const chunk of stream) { … } +}); + +// Bounded async iterable with per-yield validation +export const events = createFunction([ + asyncIterable({ + maxYields: 1000, + value: z.object({ type: z.string(), payload: z.unknown() }), + }), +])(async function events(stream) { + "use server"; + // stream: AsyncIterable<{type: string, payload: unknown}> +}); +``` + +Each helper rejects at decode with a distinct `reason` code: + +- `wire_shape_mismatch` — slot's wire tag doesn't match the spec (e.g. expected `$AT` got a primitive) +- `max_bytes_exceeded` / `max_size_exceeded` / `max_chunks_exceeded` / `max_yields_exceeded` — resource ceiling tripped +- `validate_failed` — inner Standard Schema rejected a key, value, or yielded item + +For streams and iterables, the bound is enforced **as the handler consumes them**, not at decode time. The decoder wraps the materialized stream / iterable; once the handler reads past the ceiling the wrapper errors instead of yielding more data. That matters because Flight materializes stream chunks up-front at decode — the wrap is what lets per-slot bounds tighten beyond the global `maxStreamChunks` ceiling. + +`typedArray` takes the actual constructor reference (not a string name): `typedArray({ ctor: Float32Array })` infers as `(samples: Float32Array)` in the handler, and the runtime check is `value instanceof Float32Array`. Pass an array of constructors for a union: `typedArray({ ctor: [Uint8Array, Uint8ClampedArray] })`. + + +### Dev-time warnings + + +In development, every Server Function call without a `createFunction` contract logs a one-time warning to the server console: + +> Server function `src/actions/upload.mjs#upload` called without validation — wrap the export with `createFunction({...})(handler)` from `@lazarv/react-server/function` (set `config.serverFunctions.strict = false` to silence) 🛡️ + +The action id format matches what the runtime uses internally (`#`), so you can grep straight to the source. The warning is per-action and per-process: each unwrapped action is named once, no matter how many times it's called. + +This is purely a dev guardrail — built/started servers never log it. If you're migrating an existing codebase incrementally and don't want the noise, set `config.serverFunctions.strict = false`: + +```js +// react-server.config.mjs +export default { + serverFunctions: { strict: false }, +}; +``` + + +### Error handling + + +When validation fails, the runtime returns HTTP 400 and sets the `x-react-server-action-error` header to the failure reason (`validate_failed`, `parse_failed`, `unknown_entry`, `max_bytes_exceeded`, `mime_not_allowed`, `missing_entry`, `wire_shape_mismatch`, `custom_validate_failed`, `duplicate_entry`). The client receives a generic error — schema diagnostics are not forwarded to avoid leaking expected-shape details that aid attackers. Diagnostics are written to the server log via `logger.warn` for operator visibility. + +If you need user-facing error messages tied to specific fields, validate the same payload again *inside* the handler and return a structured error object: that path is yours to design and is what `useActionState` is built around. + ## Security diff --git a/docs/src/pages/ja/(pages)/guide/server-functions.mdx b/docs/src/pages/ja/(pages)/guide/server-functions.mdx index d49f59d8..09cbff97 100644 --- a/docs/src/pages/ja/(pages)/guide/server-functions.mdx +++ b/docs/src/pages/ja/(pages)/guide/server-functions.mdx @@ -202,6 +202,238 @@ export default function App() { } ``` + +## バリデーション + + +サーバ関数はクライアントから引数を受け取ります。ブラウザからサーバのハンドラに流れ込むペイロードは、定義上「信頼されない」ものです。ランタイムはオプトインの API として `createFunction` を提供し、ランタイムの各引数スロットに対して parse / validate のコントラクトを宣言できます。このコントラクトはプロトコル層で適用されます。デコーダは引数ウォークの間にスロットごとに `parse` と `validate` を実行し、最初に失敗したスロットでリクエストを HTTP 400 として拒否します。これはハンドラが走る *前*、ビジネスロジックが値に触れる *前*、ハンドラが境界を仮定するアロケーションが起きる *前* です。 + +`createFunction` で包まれていないベアな `"use server"` 関数は、これまで通り変更なく動作します。バリデーションコントラクトはオプトインで追加的なものです。 + + +### `createFunction` + + +アクションハンドラを `createFunction(spec)(handler)` で包みます。バリデートのスロットを配列で渡すのが一番よくある形です。 + +```jsx +import { + createFunction, + formData, + file, +} from "@lazarv/react-server/function"; +import { z } from "zod"; + +export const updateProfile = createFunction([ + z.string().email(), + z.string().min(2).max(80), +])(async function updateProfile(email, name) { + "use server"; + await db.users.update({ email, name }); +}); +``` + +配列の `i` 番目は **ランタイム引数スロット `i`** を記述します。これはクライアントがワイヤ上の位置 `i` に置く値であり、ハンドラのシグネチャの引数 `i` ではありません。`.bind(...)` やインラインクロージャによるバウンドキャプチャはこのコントラクトの一部ではありません。これらは隠された値であり、AEAD のアクショントークンによって完全性が保護され、ユーザ入力としてバリデートされることはありません。 + +ランタイムは [Standard Schema](https://standardschema.dev/) — Zod、Valibot、ArkType — を `safeParse` / `assert` / `parse` の duck-type で受け付けます。特定のライブラリを `@lazarv/react-server` からインポートする必要はありません。プロジェクトで既に使っているスキーマライブラリを持ち込んでください。 + + +### parse のあとに validate + + +事前のパースも必要な場合は、オブジェクト形式 `createFunction({ validate, parse })` に切り替えます。両方とも同じスロットでインデックスされる配列です。`parse[i]` は値ツリーがマテリアライズされた後、`validate[i]` の前に実行されます。スキーマでは表現しにくい型変換を入れるのに適しています。 + +```js +export const setLimit = createFunction({ + parse: [(v) => Number(v)], + validate: [z.number().int().min(1).max(1000)], +})(async function setLimit(limit) { + "use server"; + await db.config.set({ limit }); +}); +``` + +`parse` が例外を投げると `DecodeValidationError(reason: "parse_failed")` として現れ、`validate` の失敗は `reason: "validate_failed"` になります。デコーダは最初の失敗で中断します。次のスロットには触れず、ハンドラも呼ばれません。 + + +### `noop` で特定のスロットだけスキップする + + +一部のスロットだけバリデーション / パースをかけたい場合は、スパース配列の `[, , schema]` や明示的な `undefined` ではなく、`noop` エクスポートをプレースホルダとして使います。 + +```js +import { createFunction, noop } from "@lazarv/react-server/function"; + +export const update = createFunction([noop, noop, z.number().int()])( + async function update(a, b, count) { + // a: unknown, b: unknown, count: number + // — スロット 0 / 1 は未検証で受け入れ、スロット 2 のみ制約あり。 + "use server"; + } +); +``` + +オブジェクト形式で `parse` と `validate` のギャップが揃っていないときも同じです。 + +```js +createFunction({ + parse: [noop, noop, (v) => Number(v)], + validate: [z.string(), noop, z.number()], +})(handler); +// handler: (a: string, b: unknown, c: number) +``` + +`noop` は恒等変換です。ランタイム上はそのスロットの validation / parse を省くのと同じ挙動になります。`noop` が当たるスロットは、ハンドラのシグネチャ推論では `unknown` になります。 + + +### `formData` — ファイルアップロードと構造化フォーム + + +`FormData` 引数はファイルアップロードや progressive enhancement のフォーム送信でよく使われます。スキーマライブラリはオブジェクトの形を記述できますが、それは *マテリアライズ済みの* 値です。スキーマが走る時点で、ファイルはすでにバッファされています。`formData` はワイヤ・アウェアです。デコーダは FormData ウォークの *最中* に宣言済みのキーと各エントリの制約を強制するため、サイズと MIME の制限はエントリが結果に追加される前に `Blob.size` / `Blob.type` に対して同期的にチェックされます。 + +```js +import { + createFunction, + formData, + file, +} from "@lazarv/react-server/function"; + +export const upload = createFunction([ + formData({ + title: z.string().min(1).max(120), + photo: file({ + maxBytes: 5 * 1024 * 1024, + mime: ["image/png", "image/jpeg"], + }), + }), +])(async function upload(form) { + "use server"; + const title = form.get("title"); + const photo = form.get("photo"); + // photo はサイズと MIME がすでにチェックされています。 +}); +``` + +`formData(shape, options?)` は第 1 引数にエントリのシェイプ(必須)、第 2 引数にオプション(任意)を取ります。今のところオプションは `unknown` のみですが、将来のフォーム単位制約のために予約されたスロットです。 + +デコーダは宣言済みのエントリを **完全一致のキー** で参照します。プレフィックススキャンは行われないので、`5_role=admin` のような攻撃者注入のエントリがハンドラの読む FormData に紛れ込むことはありません。 + +`unknown` ポリシーは `formData(shape, { unknown: "drop" })` のように第 2 引数で渡し、シェイプで *宣言されていない* エントリの扱いを決めます。 + +- `"reject"`(デフォルト、推奨): 未知のエントリは `DecodeValidationError(reason: "unknown_entry")` でデコードを失敗させます。攻撃者注入フィールドへの防御です。 +- `"drop"`: 宣言外のエントリを黙って捨てます。React 管理の隠しフィールドなど、スキーマで列挙したくない場合に有用です。 +- `"allow"`: 宣言外のエントリを未検証のまま素通りさせます。エスケープハッチであり、安全ではないものとしてドキュメント化しています。 + +`file({...})` と `blob({...})` は `maxBytes`(エントリごとのサイズ上限)、`mime`(許容 MIME のホワイトリスト)、オプションの同期 `validate(value)` コールバック(マジックバイト検査などのカスタムチェック)、不在を許す `optional: true` を受け付けます。`File.type` はブラウザが供給する値で、簡単に偽装できます。MIME チェックを `validate` でのマジックバイト検査と組み合わせて、強い保証を得てください。 + + +### その他のワイヤ型 + + +Flight プロトコルはプリミティブやオブジェクト、`FormData` 以外にもさまざまな型を運びます。Standard Schema 単体では足りないワイヤ型 — リソース消費を制限したい、プラットフォームのコンストラクタで型チェックしたい、非同期ソースをラップしたい場合 — それぞれにワイヤ・アウェアなヘルパーを用意しています。 + +```js +import { + createFunction, + arrayBuffer, + typedArray, + map, + set, + stream, + asyncIterable, + iterable, + promise, +} from "@lazarv/react-server/function"; +``` + +| ヘルパー | ワイヤタグ | ハンドラ側の型 | ワイヤ・アウェアである理由 | +|---|---|---|---| +| `arrayBuffer({ maxBytes })` | `$AB` | `ArrayBuffer` | ハンドラがバッファを観測する前にバイト長を制限 | +| `typedArray({ ctor, maxBytes })` | `$AT` | `InstanceType`(例: `Float32Array`) | `instanceof` でコンストラクタを許可制に。型推論にも反映 | +| `map({ maxSize, key, value })` | `$Q` | `Map` | サイズ制限・内側のキー/値スキーマ | +| `set({ maxSize, value })` | `$W` | `Set` | サイズ制限・要素ごとのスキーマ | +| `stream({ maxChunks, maxBytes })` | `$r` / `$b` | `ReadableStream` | ハンドラがストリームを消費する間に上限を強制 | +| `asyncIterable({ maxYields, value })` | `$x` | `AsyncIterable` | yield 数の上限・yield ごとのバリデーション | +| `iterable({ maxYields, value })` | `$X` | `Iterable` | 同期版で同じ仕組み | +| `promise(value)` | `$@` | `Promise` | 解決値をスキーマで検証 | + +具体例: + +```js +// 型付き配列として届くバイナリアップロード +export const upload = createFunction([ + typedArray({ ctor: Uint8Array, maxBytes: 5 * 1024 * 1024 }), +])(async function upload(bytes) { + "use server"; + // bytes: Uint8Array、サイズ済みチェック済み +}); + +// 制限付き Map 引数 +export const lookup = createFunction([ + map({ maxSize: 100, key: z.string(), value: z.number() }), +])(async function lookup(table) { + "use server"; + // table: Map、エントリ数 100 でキャップ +}); + +// ストリームごとのバイト数キャップ +export const ingest = createFunction([ + stream({ maxBytes: 50 * 1024 * 1024, maxChunks: 8192 }), +])(async function ingest(stream) { + "use server"; + // stream: ReadableStream — どちらかの上限を超えるとエラーをスロー + for await (const chunk of stream) { … } +}); + +// yield ごとに検証する非同期イテラブル +export const events = createFunction([ + asyncIterable({ + maxYields: 1000, + value: z.object({ type: z.string(), payload: z.unknown() }), + }), +])(async function events(stream) { + "use server"; + // stream: AsyncIterable<{type: string, payload: unknown}> +}); +``` + +各ヘルパーはデコード時に異なる `reason` で拒否します。 + +- `wire_shape_mismatch` — スロットのワイヤタグが仕様と一致しない(例: `$AT` を期待したのにプリミティブ) +- `max_bytes_exceeded` / `max_size_exceeded` / `max_chunks_exceeded` / `max_yields_exceeded` — リソース上限超過 +- `validate_failed` — 内側の Standard Schema がキー / 値 / yielded 要素を拒否 + +ストリームとイテラブルでは、上限は **ハンドラが消費する時点** で強制されます。デコーダはマテリアライズしたストリーム / イテラブルをラップし、ハンドラが上限を超えて読みに行った瞬間にラッパーが値を流す代わりにエラーを発生させます。Flight プロトコルはチャンクをデコード時に先読みするため、このラッパーがあって初めてグローバルな `maxStreamChunks` 上限よりタイトなスロット単位の制限が効きます。 + +`typedArray` は文字列名ではなく **コンストラクタの参照** を取ります。`typedArray({ ctor: Float32Array })` でハンドラの型が `(samples: Float32Array)` に推論され、ランタイムの判定は `value instanceof Float32Array` です。複数許可したい場合は配列で: `typedArray({ ctor: [Uint8Array, Uint8ClampedArray] })`。 + + +### 開発時の警告 + + +開発時には、`createFunction` のコントラクトを持たないサーバ関数の呼び出しごとに、サーバコンソールへ一度だけ警告が記録されます。 + +> Server function `src/actions/upload.mjs#upload` called without validation — wrap the export with `createFunction({...})(handler)` from `@lazarv/react-server/function` (set `config.serverFunctions.strict = false` to silence) 🛡️ + +アクション ID のフォーマットはランタイムが内部で使うもの(`#`)と一致するので、grep でそのままソースに飛べます。警告はアクション単位・プロセス単位で 1 回だけです。同じアクションを何度呼び出しても、名前が出るのは最初の一回だけです。 + +これは純粋に開発時のガードレールです。ビルド・起動後のサーバではログに出ません。既存コードベースを段階的に移行している最中でノイズを止めたい場合は、`config.serverFunctions.strict = false` を設定してください。 + +```js +// react-server.config.mjs +export default { + serverFunctions: { strict: false }, +}; +``` + + +### エラーハンドリング + + +バリデーションが失敗するとランタイムは HTTP 400 を返し、`x-react-server-action-error` ヘッダに失敗理由(`validate_failed`、`parse_failed`、`unknown_entry`、`max_bytes_exceeded`、`mime_not_allowed`、`missing_entry`、`wire_shape_mismatch`、`custom_validate_failed`、`duplicate_entry`)を入れます。クライアントには汎用的なエラーが届きます。期待されるシェイプの詳細を攻撃者に漏らさないため、スキーマの診断はクライアントに転送しません。診断はサーバログに `logger.warn` で書き出され、運用者が確認できます。 + +特定のフィールドに紐づいたユーザ向けエラーメッセージが必要な場合は、ハンドラの *中* で同じペイロードに対してもう一度バリデーションし、構造化されたエラーオブジェクトを返してください。これは設計上あなたが扱う領域であり、`useActionState` がそのために用意されています。 + ## セキュリティ @@ -305,4 +537,4 @@ export default { ### 制限事項 -トークンはバインド配列内の値を保護します。キャプチャされた値が `File` や `Blob` の場合、トークンはバイナリコンテンツへのスロット参照を保護しますが、バイナリコンテンツそのものは保護しません。サーバ側で構築したバイナリデータをクロージャにバインドするサーバ関数は、有効なトークンであっても、アップロードを制御する攻撃者によってバイナリコンテンツが差し替えられる可能性があることに注意してください。実際にはサーバ関数のクロージャでキャプチャされる `File` / `Blob` はまれです。 \ No newline at end of file +トークンはバインド配列内の値を保護します。キャプチャされた値が `File` や `Blob` の場合、トークンはバイナリコンテンツへのスロット参照を保護しますが、バイナリコンテンツそのものは保護しません。サーバ側で構築したバイナリデータをクロージャにバインドするサーバ関数は、有効なトークンであっても、アップロードを制御する攻撃者によってバイナリコンテンツが差し替えられる可能性があることに注意してください。実際にはサーバ関数のクロージャでキャプチャされる `File` / `Blob` はまれです。 diff --git a/examples/typed-file-router/pages/(root).layout.tsx b/examples/typed-file-router/pages/(root).layout.tsx index f389d63c..cd53fb7d 100644 --- a/examples/typed-file-router/pages/(root).layout.tsx +++ b/examples/typed-file-router/pages/(root).layout.tsx @@ -6,6 +6,7 @@ import { counter, clock, todos, + guestbook, product, productSkuUppercase, docs, @@ -37,6 +38,7 @@ export default index.createLayout(({ children }) => { Counter Clock Todos + Guestbook Product ABC-123 (matcher) diff --git a/examples/typed-file-router/pages/guestbook.page.tsx b/examples/typed-file-router/pages/guestbook.page.tsx new file mode 100644 index 00000000..cafc9aaf --- /dev/null +++ b/examples/typed-file-router/pages/guestbook.page.tsx @@ -0,0 +1,64 @@ +/** + * Guestbook page — server component reading entries directly, with a + * client island below for the `createFunction` actions. + * + * Demonstrates `createFunction` with Zod schemas in the file-router + * setup. The page itself is a server component (async, reads from the + * server-side store); the form is a client island that calls the + * typed actions and refreshes the parent on success. + */ +import { guestbook } from "@lazarv/react-server/routes"; + +import { listEntries } from "../src/guestbook-actions"; +import DeleteEntryButton from "../src/DeleteEntryButton"; +import GuestbookForm from "../src/GuestbookForm"; + +export const route = "guestbook"; + +export default guestbook.createPage(async () => { + const entries = await listEntries(); + + return ( +
+

Guestbook

+

+ Server functions wrapped with{" "} + createFunction([zod-schema])(handler). Validation runs at + the protocol layer — submit an empty message and the request returns + HTTP 400 with x-react-server-action-error: validate_failed{" "} + before the handler ever runs. +

+ +
    + {entries.length === 0 && ( +
  • (no entries yet)
  • + )} + {entries.map((entry) => ( +
  • +
    +
    + {entry.name} + +
    + + {new Date(entry.createdAt).toLocaleString()} + +
    +
    {entry.message}
    +
  • + ))} +
+ + +
+ ); +}); diff --git a/examples/typed-file-router/src/DeleteEntryButton.tsx b/examples/typed-file-router/src/DeleteEntryButton.tsx new file mode 100644 index 00000000..d9c64118 --- /dev/null +++ b/examples/typed-file-router/src/DeleteEntryButton.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTransition } from "react"; +import { useClient } from "@lazarv/react-server/client"; +import { deleteEntry } from "./guestbook-actions"; + +/** + * Tiny client island for the per-entry delete button. + * + * Each entry rendered by the server component + * (`pages/guestbook.page.tsx`) gets one of these next to it. The `id` + * arrives via props from the server — typed end-to-end as `number`, + * narrowed by Zod's `.coerce.number().int().positive()` at the action + * boundary. + * + * After a successful delete, the runtime's `refresh()` re-runs the + * parent server component so the list reflects the change. + */ +export default function DeleteEntryButton({ id }: { id: number }) { + const { refresh } = useClient(); + const [pending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/examples/typed-file-router/src/GuestbookForm.tsx b/examples/typed-file-router/src/GuestbookForm.tsx new file mode 100644 index 00000000..81867345 --- /dev/null +++ b/examples/typed-file-router/src/GuestbookForm.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useClient } from "@lazarv/react-server/client"; +// Hover over each in your editor: +// +// addEntry: (input: { name: string; message: string }) => Promise +// deleteEntry: (id: number) => Promise<{ id: number; deleted: boolean }> +// +// Both signatures are inferred from the Zod schemas in +// `src/guestbook-actions.ts` via the `InferArgs` machinery. +import { addEntry, deleteEntry } from "./guestbook-actions"; + +/** + * Client island that drives the createFunction-wrapped actions. + * + * Three sections: + * + * 1. The add form — a normal happy-path submission. + * 2. The "try a bad payload" panel — three buttons that build inputs + * passing TypeScript's check but failing the Zod schema at the + * protocol layer. The runtime rejects with HTTP 400 and the + * handler never runs; the rejection surfaces as a thrown error + * that the catch block displays to make the rejection visible. + * + * After every successful mutation we call `refresh()` from + * `useClient()` so the parent server component re-renders and the + * updated list flows back via RSC. + */ +export default function GuestbookForm() { + const { refresh } = useClient(); + const [pending, startTransition] = useTransition(); + const [lastError, setLastError] = useState(null); + const [lastOk, setLastOk] = useState(null); + + const run = (label: string, fn: () => Promise, after?: () => void) => + startTransition(async () => { + setLastError(null); + setLastOk(null); + try { + await fn(); + setLastOk(`${label} succeeded`); + after?.(); + } catch (err) { + setLastError( + `${label} → ${err instanceof Error ? err.message : String(err)}` + ); + } + }); + + return ( +
+

Add an entry

+ +
{ + e.preventDefault(); + // Capture synchronously — React nullifies `e.currentTarget` + // before the async transition body runs. + const form = e.currentTarget; + const fd = new FormData(form); + const name = String(fd.get("name") ?? ""); + const message = String(fd.get("message") ?? ""); + run( + "addEntry (form)", + () => addEntry({ name, message }), + () => { + form.reset(); + return refresh(); + } + ); + }} + style={{ display: "grid", gap: "0.5rem", maxWidth: 480 }} + > + +