@k8ordo/server

アクションとリクエスト

Server Action は、クライアントから呼べてサーバーで動く関数で、このモードにはその届き先があります。このページは、アクションの書き方、JavaScript が無くても動くフォーム、redirect() での終え方、ページがリクエストを読む方法を説明します。

'use server' で宣言する

先頭に 'use server' を書いたモジュールの export は、すべて Server Action になり、どれも async でなければなりません。クライアントコンポーネントはそれを import して呼べ、呼び出しはサーバーで動きます。

// src/routes/_data/talks.server.ts
import 'server-only';

const talks: string[] = [];

export const saveTalk = (title: string): Promise<void> => {
  talks.push(title);
  return Promise.resolve();
};
// src/routes/_parts/actions.ts
'use server';

import { saveTalk } from '../_data/talks.server';

export type TalkState = { error?: string };

export async function createTalk(
  _previous: TalkState,
  formData: FormData,
): Promise<TalkState> {
  const title = formData.get('title');
  if (typeof title !== 'string' || title === '') {
    return { error: 'a title is required' };
  }
  await saveTalk(title);
  return {};
}

useActionState にアクションを渡すと、フォームの action と、前回の結果と、送信中かどうかが返ります。アクションは前回の状態と FormData を受け取り、次の状態を返します。

// src/routes/_parts/talk-form.tsx
'use client';

import { useActionState } from 'react';

import { createTalk } from './actions';

export function TalkForm() {
  const [state, formAction, pending] = useActionState(createTalk, {});
  return (
    <form action={formAction}>
      <input aria-label="title" name="title" />
      <button disabled={pending} type="submit">
        add
      </button>
      {state.error === undefined ? null : <p role="alert">{state.error}</p>}
    </form>
  );
}

1 往復で画面まで新しくなる

アクションを呼ぶと、サーバーは現在のページを描き直し、戻り値と一緒に返します。呼び出し側が値を受け取った時点で、画面もすでに新しくなっています。往復は 2 回ではなく 1 回です。

JavaScript が読み込まれる前から動くフォーム

React はアクションを識別するフィールドを HTML に描きます。それを送るのは普通のフォーム送信で、サーバーはアクションを実行し、同じページを描き直した HTML で答えます。useActionState の結果はこの往復の後にも残るので、同じコンポーネントが JavaScript の有無を知らないまま、どちらでも動きます。

'use server'server-only は別のことを言う

'use server' はクライアントが呼んでよい関数、つまりサーバーで動く関数を印付けます。server-only はクライアントが決して届いてはいけないモジュールを印付けます。アクションのモジュールに要るのは前者だけです。秘密やデータベースのクライアントは *.server.ts に置き、アクションからそれを import します。上の createTalk がその形です。

アクションのモジュールを *.server.ts と名付けないのも同じ理由です。クライアントから import されることが、このファイルの目的だからです。

redirect() で終える

@k8ordo/server/runtimeredirect(to) は、訪問者を別の場所へ送ってアクションを終えます。値を返すのではなく throw するので、その後の行は走りません。Server Component が <form action> にアクションを直接渡す形なら、クライアントコンポーネントは 1 つも要りません。

// src/routes/_parts/leave.ts
'use server';

import { redirect } from '@k8ordo/server/runtime';

import { saveTalk } from '../_data/talks.server';

export async function addAndLeave(formData: FormData): Promise<void> {
  const title = formData.get('title');
  if (typeof title === 'string' && title !== '') {
    await saveTalk(title);
  }
  redirect('/products');
}
// src/routes/page.tsx
import { addAndLeave } from './_parts/leave';

export default function HomePage() {
  return (
    <form action={addAndLeave}>
      <input aria-label="title" name="title" />
      <button type="submit">add and leave</button>
    </form>
  );
}

JavaScript なしで送られたフォームには 303location で答え、ブラウザは行き先を GET で読み込みます。クライアントのランタイムからの呼び出しには、行き先へ移動するよう伝えるペイロードで答え、ルーターがそこへ遷移します。リダイレクトしたアクションは、ページを描きません。

redirect() は throw で終わるので、try の中で呼ぶと catch がそれを受け取ってしまいます。try の外で呼びます。

ページの描画中に訪問者を別の場所へ送る API はありません。redirect() が効くのは Server Action の中だけで、描画中に呼ぶとただのエラーとして throw され、error.tsx500 になります。移転した URL には redirect.ts を置きます。 エラーとリダイレクト

リクエストを読む

ページとレイアウト(と not-found.tsx)は、paramspathname の横で request を受け取ります。中身はヘッダーと、名前ごとにパースされた cookie です。

// src/routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';

export default function RootLayout({ children, request }: LayoutProps<'/'>) {
  const theme = request.cookies.get('theme') === 'dark' ? 'dark' : 'light';
  const language = request.headers.get('accept-language') ?? 'en';
  return (
    <html data-theme={theme} lang={language.split(',')[0]}>
      <body>{children}</body>
    </html>
  );
}
フィールド中身
headersHeadersリクエストのヘッダー
cookiesReadonlyMap<string, string>Cookie ヘッダーを名前ごとにパースしたもの。同じ名前が 2 度あれば最初のものを取り、値を囲む引用符を外して URL デコードします(デコードできなければ送られたまま)。

PagePropsLayoutPropsrequest を持つのは、生成された .k8ordo/register.gen.ts が、このモードにはリクエストがあると言っているからです。@k8ordo/server/runtimeRouteRequest はその型で、下のコンポーネントに prop として渡すときに使います。クライアントコンポーネントには丸ごと渡さず、要る値だけを取り出して渡します。Headers は境界を越えられません。

// src/routes/_parts/greeting.tsx
import type { RouteRequest } from '@k8ordo/server/runtime';

export function Greeting({ request }: { request: RouteRequest }) {
  return <p>{request.cookies.get('name') ?? 'welcome'}</p>;
}

ページには応答を書く手段がありません。ステータスも Set-Cookie も書けません。ページは描画であり、リクエストに答える描画は 2 つ目のハンドラになってしまうからです。

このフィールドはこのモードにしかありません。@k8ordo/static の下では生成される PageLayout の型に request が無いので、それを読むページは型チェックで落ちます。search も含まれません。search は @k8ordo/state のもので、ブラウザで読みます。

別オリジンからの POST は拒まれる

Server Action は、POST できる場所ならどこからでも名前で呼べてしまう関数です。訪問者の cookie を付けたまま、別のサイトのフォームから送られることもあります。ハンドラは POST の Origin ヘッダーのホストが、答えている URL のホストと一致するときだけ受け付け、それ以外には 403 で答えます。この検査はアクションに限らずすべての POST に掛かるので、Origin を付けない curl や Webhook の POST も 403 になります。

プロキシの後ろで動かすときの注意は、リンク先にあります。 実行と配信

@k8ordo/form と組む

@k8ordo/form は、フォームの制約属性・メッセージ・サーバー側の検証を 1 つの zod スキーマから導きます。次は examples/server-basic のゲストブックで、JavaScript なしで送っても、フィールドごとのエラーと入力した値を持って戻ってきます。

// src/routes/_parts/guestbook-schema.ts
import * as z from 'zod/mini';

z.config(z.locales.en());

export const guestbookSchema = z.object({
  name: z.string().check(z.minLength(1), z.maxLength(40)),
});
// src/routes/_parts/guestbook.ts
'use server';

import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';

import { guestbookSchema } from './guestbook-schema';

const entries: string[] = [];

export async function sign(
  _previous: FormState,
  formData: FormData,
): Promise<FormState> {
  const parsed = parseForm(guestbookSchema, formData);
  if (!parsed.success) return parsed.state;
  entries.push(parsed.data.name);
  return {};
}
// src/routes/_parts/guestbook-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { sign } from './guestbook';

export function GuestbookForm({ fields }: { fields: FormFields<'name'> }) {
  const [state, formAction] = useActionState(sign, {});
  const form = useForm(fields, state);
  const name = form.field('name');
  return (
    <form {...form.props} action={formAction}>
      <input aria-label="name" {...name.input} />
      <button type="submit">sign</button>
      {name.error === undefined ? null : <p>{name.error}</p>}
    </form>
  );
}
// src/routes/page.tsx
import { formFields } from '@k8ordo/form/server';

import { GuestbookForm } from './_parts/guestbook-form';
import { guestbookSchema } from './_parts/guestbook-schema';

const guestbookFields = formFields(guestbookSchema);

export default function HomePage() {
  return <GuestbookForm fields={guestbookFields} />;
}

スキーマから属性と文言を導くのは Server Component の側で、結果は素の JSON として props でクライアントに渡ります。zod はブラウザに届きません。 @k8ordo/form

動かすことで得られるもの

このモードが @k8ordo/static に対して持つのは次のものです。どれも要らなければ、@k8ordo/static が同じアプリをファイルに書き出します。文法も境界もハンドラも同じで、ハンドラがビルド時にルートごとに呼ばれるだけです。

  • 知らない URL への、ホスティングの答えではなくアプリ自身の本物の 404
  • 値の一覧が要らないパラメータ付きルート。カタログが変わっても再ビルドは要りません
  • フォームが送れる Server Action と、そこからの redirect()
  • ページから読めるリクエストのヘッダーと cookie

@k8ordo/static