サーバ関数
サーバ関数はクライアント側から呼び出すことができる非同期関数です。これらの関数を呼び出すためにAPIエンドポイントを実装する必要はなく、クライアントは関数自体への直接参照のようにこれらの関数を呼び出すことができます。サーバ関数はフォーム、ボタン、送信入力、およびクライアントコンポーネントへのpropsとして使用できます。Reactとランタイムはこれらの関数をサーバ側で呼び出すことを管理します。
任意の "use server"; とマークされた関数をサーバ関数として公開することができます。サーバ関数は、<form> 要素の action prop、<button> または <input> 要素の formAction、またはクライアントコンポーネントにサーバ関数をpropとして渡すことでクライアントから呼び出すことができます。
TypeScriptを使用している場合、すべてのサーバ関数は型安全であり、サーバ関数に間違ったパラメータを渡したり、存在しないプロパティにアクセスしようとした場合に型エラーが発生します。
サーバ関数は、他のReactコンポーネントのイベントハンドラと同様に、コンポーネント内でインラインで定義することができます。
export default function App() {
async function action() {
"use server";
console.log("Server function called!");
}
return (
<form action={action}>
<button type="submit">Submit</button>
</form>
);
}
プログレッシブエンハンスメント: JavaScriptが有効になっている場合、サーバ関数は
fetchAPIを使用して呼び出され、その応答はReactのトランジションでDOMを更新するために使用されます。JavaScriptが無効になっている場合、サーバ関数は通常のHTTPリクエストを使用して呼び出されます。
サーバ関数をJSX内でインラインで定義することもできます。
export default function App() {
return (
<form
action={async () => {
"use server";
console.log("Server function called!");
}}
>
<button type="submit">Submit</button>
</form>
);
}
サーバ関数はスコープ内のすべての変数にアクセスできます。これには、propsへの参照や、サーバコンポーネントの最後のレンダリングからサーバ関数のスコープ内で利用可能な変数が含まれます。これは、サーバコンポーネントがレンダーされるたびにサーバー関数がマッピングされるためです。
サーバ関数を別のモジュールに分けて管理したい場合は、モジュールの先頭に "use server"; プラグマを使用することで実現できます。サーバ関数モジュールからエクスポートされたすべての関数は、サーバ関数として使用可能になります。
"use server";
export async function action() {
console.log("Server function called!");
}
サーバ関数は、第一パラメータにオブジェクトとしてすべてのフォームデータを取得します。
export default function App() {
async function action(formData) {
"use server";
console.log(`Server function called by ${formData.get("name")}!`);
}
return (
<form action={action}>
Your name: <input name="name" />
<button type="submit">Submit</button>
</form>
);
}
import { useActionState } from "@lazarv/react-server/router";
export default function App() {
async function action(formData) {
"use server";
console.log(`Server function called by ${formData.get("name")}!`);
}
const { error } = useActionState(action);
return (
<form action={action}>
Your name: <input name="name" />
<button type="submit">Submit</button>
{error && <p>{error.message}</p>}
</form>
);
}
アクションの状態にアクセスするには、useActionState フックを使用できます。useActionState フックは、サーバ関数を第一パラメータとして受け取り、以下のプロパティを持つオブジェクトを返します:
formData: フォームデータオブジェクトdata: サーバ関数から返されたデータオブジェクトerror: アクションが失敗した場合のエラーオブジェクトactionId: 現在のアクションのアクションID
サーバ関数への参照をクライアントコンポーネントにpropsとして渡し、他の非同期関数と同様にクライアントコンポーネントから呼び出すこともできます。
"use client";
export default function MyClientComponent({ action }) {
const handleClick = () => {
action({ name: "John" });
};
return <button onClick={handleClick}>Click me!</button>;
}
import MyClientComponent from "./MyClientComponent";
export default function App() {
async function action({ name }) {
"use server";
console.log(`Server function called by ${name}!`);
}
return (
<div>
<MyClientComponent action={action} />
</div>
);
}
クライアントコンポーネントから呼び出されたサーバ関数は、直接クライアントコンポーネントにデータを返すことができます。これは、サーバ関数が完了した後にユーザーにメッセージを表示したり、サーバ関数が失敗した場合にエラーメッセージを表示したりする場合に便利です。
"use client";
export default function MyClientComponent({ action }) {
const [response, setResponse] = useState(null);
const handleClick = async () => {
const response = await action({ name: "John" });
setResponse(response);
};
return (
<>
<button onClick={handleClick}>Click me!</button>
{response && <p>{response.message}</p>}
</>
);
}
import MyClientComponent from "./MyClientComponent";
export default function App() {
async function action({ name }) {
"use server";
console.log(`Server function called by ${name}!`);
return { message: `Hello ${name}!` };
}
return (
<div>
<MyClientComponent action={action} />
</div>
);
}
サーバ関数はクライアントから引数を受け取ります。ブラウザからサーバのハンドラに流れ込むペイロードは、定義上「信頼されない」ものです。ランタイムはオプトインの API として createFunction を提供し、ランタイムの各引数スロットに対して parse / validate のコントラクトを宣言できます。このコントラクトはプロトコル層で適用されます。デコーダは引数ウォークの間にスロットごとに parse と validate を実行し、最初に失敗したスロットでリクエストを HTTP 400 として拒否します。これはハンドラが走る 前、ビジネスロジックが値に触れる 前、ハンドラが境界を仮定するアロケーションが起きる 前 です。
createFunction で包まれていないベアな "use server" 関数は、これまで通り変更なく動作します。バリデーションコントラクトはオプトインで追加的なものです。
アクションハンドラを createFunction(spec)(handler) で包みます。バリデートのスロットを配列で渡すのが一番よくある形です。
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 — Zod、Valibot、ArkType — を safeParse / assert / parse の duck-type で受け付けます。特定のライブラリを @lazarv/react-server からインポートする必要はありません。プロジェクトで既に使っているスキーマライブラリを持ち込んでください。
事前のパースも必要な場合は、オブジェクト形式 createFunction({ validate, parse }) に切り替えます。両方とも同じスロットでインデックスされる配列です。parse[i] は値ツリーがマテリアライズされた後、validate[i] の前に実行されます。スキーマでは表現しにくい型変換を入れるのに適しています。
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" になります。デコーダは最初の失敗で中断します。次のスロットには触れず、ハンドラも呼ばれません。
一部のスロットだけバリデーション / パースをかけたい場合は、スパース配列の [, , schema] や明示的な undefined ではなく、noop エクスポートをプレースホルダとして使います。
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 のギャップが揃っていないときも同じです。
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 引数はファイルアップロードや progressive enhancement のフォーム送信でよく使われます。スキーマライブラリはオブジェクトの形を記述できますが、それは マテリアライズ済みの 値です。スキーマが走る時点で、ファイルはすでにバッファされています。formData はワイヤ・アウェアです。デコーダは FormData ウォークの 最中 に宣言済みのキーと各エントリの制約を強制するため、サイズと MIME の制限はエントリが結果に追加される前に Blob.size / Blob.type に対して同期的にチェックされます。
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 単体では足りないワイヤ型 — リソース消費を制限したい、プラットフォームのコンストラクタで型チェックしたい、非同期ソースをラップしたい場合 — それぞれにワイヤ・アウェアなヘルパーを用意しています。
import {
createFunction,
arrayBuffer,
typedArray,
map,
set,
stream,
asyncIterable,
iterable,
promise,
} from "@lazarv/react-server/function";
| ヘルパー | ワイヤタグ | ハンドラ側の型 | ワイヤ・アウェアである理由 |
|---|---|---|---|
arrayBuffer({ maxBytes }) | $AB | ArrayBuffer | ハンドラがバッファを観測する前にバイト長を制限 |
typedArray({ ctor, maxBytes }) | $AT | InstanceType<C>(例: Float32Array) | instanceof でコンストラクタを許可制に。型推論にも反映 |
map({ maxSize, key, value }) | $Q | Map<K, V> | サイズ制限・内側のキー/値スキーマ |
set({ maxSize, value }) | $W | Set<T> | サイズ制限・要素ごとのスキーマ |
stream({ maxChunks, maxBytes }) | $r / $b | ReadableStream | ハンドラがストリームを消費する間に上限を強制 |
asyncIterable({ maxYields, value }) | $x | AsyncIterable<T> | yield 数の上限・yield ごとのバリデーション |
iterable({ maxYields, value }) | $X | Iterable<T> | 同期版で同じ仕組み |
promise(value) | $@ | Promise<T> | 解決値をスキーマで検証 |
具体例:
// 型付き配列として届くバイナリアップロード
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<string, number>、エントリ数 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#uploadcalled without validation — wrap the export withcreateFunction({...})(handler)from@lazarv/react-server/function(setconfig.serverFunctions.strict = falseto silence) 🛡️
アクション ID のフォーマットはランタイムが内部で使うもの(<modulePath>#<exportName>)と一致するので、grep でそのままソースに飛べます。警告はアクション単位・プロセス単位で 1 回だけです。同じアクションを何度呼び出しても、名前が出るのは最初の一回だけです。
これは純粋に開発時のガードレールです。ビルド・起動後のサーバではログに出ません。既存コードベースを段階的に移行している最中でノイズを止めたい場合は、config.serverFunctions.strict = false を設定してください。
// 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 がそのために用意されています。
サーバ関数の呼び出しはネットワーク越しのラウンドトリップを経由します。ランタイムは関数への参照を発行し、クライアントがそれを呼び出し、ランタイムがその関数をサーバ側で解決して実行します。このラウンドトリップにおいて改ざん検知可能であるべきものが二つあります。呼び出される対象アクションの ID と、それに伴って渡される キャプチャされた値 です。
すべてのサーバ関数の参照は、単一の AES-256-GCM トークンとしてエンコードされます。このトークンの平文は (actionId, bound) のペアであり、bound はレンダリング時に .bind(...) またはインラインクロージャによってキャプチャされた値の配列、もしくは引数を持たないアクションの場合は null です。クライアントが目にするのは暗号文のみで、アクションの呼び出し時にもこの暗号文がそのまま送り返されます。
function ProfilePage({ userId }) {
return (
<form
action={async (formData) => {
"use server";
await db.users.update(userId, formData.get("name"));
}}
>
…
</form>
);
}
上記の例では、userId がインラインのサーバ関数によってキャプチャされています。ランタイムは、アクションの ID とキャプチャされた userId の両方を一つのトークンに束ねて発行します。クライアントは userId を平文で目にすることはなく、別の値としてラウンドトリップさせることもできず、トークンの認証タグを無効化せずに編集することはできません — 認証タグが壊れた場合、その呼び出しは拒否されます。
これはサーバ関数のあらゆる形式に適用されます:
- モジュールスコープの
"use server"関数 (キャプチャなし → bound はnull) - レンダリング時にキャプチャを行うインラインクロージャ
- サーバ関数を部分適用するためのサーバ側
.bind(...)の利用 - 他のサーバ関数の引数として渡されるバインド済みのサーバ関数参照
設定すべきプロパティは一つだけです — 永続化された暗号化シークレットです。これがない場合、ランタイムはプロセスごとに一時的な鍵を生成します。開発時には問題ありませんが、サーバの再起動を跨ぐトークンや、複数インスタンス間で有効なトークンは得られません。
// react-server.config.mjs
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET, // 32 バイトの hex 文字列、または任意の文字列 (32 バイトにハッシュされます)
},
};
鍵は次の順序で解決されます:
REACT_SERVER_FUNCTIONS_SECRET環境変数REACT_SERVER_FUNCTIONS_SECRET_FILE環境変数 (ファイルへのパス)- ランタイム設定の
serverFunctions.secret - ランタイム設定の
serverFunctions.secretFile(ファイルへのパス) - ランダムな一時的な鍵 (開発時のフォールバックのみ)
処理中のトークンを失効させずに鍵をローテーションするには、以前の鍵を serverFunctions.previousSecrets (またはファイルの場合は serverFunctions.previousSecretFiles) に列挙します。受信したトークンはまず主鍵で検証され、その後、各以前の鍵が順に試されます:
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET,
previousSecrets: [process.env.ACTION_SECRET_PREVIOUS],
},
};
同じローテーションがアクション ID とバインドされたキャプチャの両方に適用されます — 鍵は一つだけです。
バインド済みのサーバ関数がクライアントコンポーネントに渡され、クライアントがさらに .bind(...) を呼んで引数を追加した場合、これらの追加引数は新しいキャプチャではなく ランタイム引数 として扱われます。これらは通常の呼び出し引数 (ユーザが呼び出し時に渡すものと同様) として送られ、暗号化されたトークンには含まれません。これは意図的な仕様です — サーバから発行されたバインドのみが整合性で保護され、クライアントが追加した引数は実質的にクライアントが呼び出し時に送ることを選んだ値に過ぎません。
アプリケーションがサーバ関数を一切持たない場合、ランタイム設定で serverFunctions: false を指定してください。これによりランタイムはアクションリクエストの復号を一切行わなくなります。受信した POST / PUT / PATCH / DELETE トラフィックはアクション呼び出しとしてパースされず、マニフェストも参照されず、リクエストは通常のページレンダリングへとフォールスルーします。アクションディスパッチを攻撃対象から取り除き、リクエストごとのわずかなオーバーヘッドも削減できます。
// react-server.config.mjs
export default {
serverFunctions: false,
};
"use server" モジュールやインラインサーバ関数を一切含まない本番ビルドでは、ランタイムが空のマニフェストを自動検出し、明示的な設定なしで同じゲートを適用します。明示的な false は、ビルド前 (開発時) からゲートを効かせたい場合や、ビルド結果に依存しない明確な姿勢として無効化したい場合に使用します。
リモートコンポーネントのレンダリングも同じ多層防御の方針で remoteComponents: false により強制的に無効化できます。マイクロフロントエンドのページの リモートコンポーネントのレンダリングを無効化する を参照してください。
トークンはバインド配列内の値を保護します。キャプチャされた値が File や Blob の場合、トークンはバイナリコンテンツへのスロット参照を保護しますが、バイナリコンテンツそのものは保護しません。サーバ側で構築したバイナリデータをクロージャにバインドするサーバ関数は、有効なトークンであっても、アップロードを制御する攻撃者によってバイナリコンテンツが差し替えられる可能性があることに注意してください。実際にはサーバ関数のクロージャでキャプチャされる File / Blob はまれです。