@k8ordo/server

パラメータ

パラメータは文字列で届き、スキーマを宣言すれば型の付いた値になります。このモードでは値がリクエストと一緒に届くので、一覧は要らず、知らない値は本物の 404 になります。

パラメータは文字列で届く

URL が運べるのは文字列だけなので、何も宣言しなければ params の値はすべて文字列です。[id] の下のページは params.id: string を受け取ります。

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return <h1>{params.id.toUpperCase()}</h1>;
}

スキーマで形を言う

page.tsxlayout.tsxparamsSchema を export して、受け取るものの形を言えます。レイアウトのスキーマは、その下のすべてのページに効きます。名前が params でないのは、ページ自身の prop が params で、同じ名前のモジュール変数はそれを隠してしまうからです。

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';

export const paramsSchema = z.object({
  id: z.coerce.number().check(z.int(), z.positive()),
});

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return <h1>{params.id.toFixed(0)}</h1>;
}

Standard Schema を実装したライブラリなら何でも使えます。zod、zod/mini、ほかのライブラリ、そして @k8ordo/i18nlocales.paramsSchema もそうです。仕様はリンク先にあります。 Standard Schema

export はファイルを構文解析して見つけるので、書き方は問いません。export const paramsSchema = …export { paramsSchema }、分割代入の export const { paramsSchema } = locales のどれも export です。文字列やコメントの中の同じ単語と export type は数えません。読まれるのは page.tsxlayout.tsx だけで、構文として解析できないファイルは何も宣言していないものとして扱われます。

スタックに沿ってスキーマが順に走る

ページが描かれる前に、そのページの上で宣言されたスキーマが外側のレイアウトから順に走り、最後にページ自身のものが走ります。それぞれは自分が名指した文字列を自分の出力で置き換え、どのスキーマも名指さなかったパラメータは文字列のまま残ります。

次の例では、[locale] のレイアウトがロケールを、ページが id を検証し、ページは両方の結果を受け取ります。

// src/i18n.ts
import { defineLocales } from '@k8ordo/i18n';

export const locales = defineLocales(['ja', 'en']);
// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';

export const { paramsSchema } = locales;

export default function LocaleLayout({ children }: { children: ReactNode }) {
  return <>{children}</>;
}
// src/routes/[locale]/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';

export const paramsSchema = z.object({
  id: z.coerce.number().check(z.int(), z.positive()),
});

export default function ProductPage({
  params,
}: PageProps<'/:locale/products/:id'>) {
  return <h1 lang={params.locale}>{params.id.toFixed(0)}</h1>;
}

拒まれた値には、そのパターンが答えない

スキーマが値を拒んでも、/products/shoesNaN を持ったページになることはありません。照合はそのパターンが一致しなかったものとして表の次へ進み、最後は not-found.tsx が 404 として答えます。ディレクトリが最初から一致しなかったのと同じです。

このモードでは、拒まれた値への応答は本物の 404 ステータスで、本文は not-found.tsx です。文書の読み込みでも、クライアント遷移が取りに行くペイロードでも同じです。

catch-all 自身のパラメータは検証されません。catch-all はほかのどれも答えなかったものに答えるので、値を拒んだときに返るはずの 404 がすでにそこにあります。/:locale/*not-found.tsx が受け取る params.locale は、どんな文字列でもありえます。

スキーマは同期的に

どのパターンが pathname に答えるかは、何かが描かれる前に決まり、その判断は待てません。非同期に検証するスキーマは、描画の前に次のエラーで拒まれます。値が形として正しいかはスキーマが、データとして存在するかはページが確かめます。

TypeError: a params schema must validate synchronously which pattern answers a pathname is decided before anything renders

スキーマは Server Component のファイルに置く

'use client' のモジュールから export した値は、RSC 側にはスキーマではなく client reference として届き、ハンドラはそれを走らせられません。クライアントコンポーネントにしたいレイアウトは、スキーマを Server Component の layout.tsx に置き、そこからクライアント側の殻を描きます。このサイトの [locale] レイアウトがその形です。

// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';

export const { paramsSchema } = locales;

export default function LocaleLayout({
  params,
  children,
}: {
  params: { locale: string };
  children: ReactNode;
}) {
  return <LocaleShell locale={params.locale}>{children}</LocaleShell>;
}

殻の側の書き方は、リンク先にあります。 実行境界

ページもリンクも、スキーマの出力を受け取る

生成された Register は、パターンごとにスキーマの出力型を持っています。PageProps<'/products/:id'>params.id は number になり、href('/products/:id', { id: 42 }) も number を受け取って、スキーマが読み戻せる 1 通りの綴りで書きます。そこに文字列やオブジェクトを渡すと型エラーです。

// src/routes/products/page.tsx
import { href } from '@k8ordo/router';

export default function ProductsPage() {
  return (
    <ul>
      {[1, 2, 3].map((id) => (
        <li key={id}>
          <a href={href('/products/:id', { id })}>
            {`product ${String(id)}`}
          </a>
        </li>
      ))}
    </ul>
  );
}

生成された表は各スキーマを satisfies ParamsSchemaFor<pattern> で検査しますが、この検査は緩やかです。パラメータを持つパターンで、そのどれも名指さないスキーマは、tsc.k8ordo/routes.gen.ts でエラーにします。一方、実在するパラメータと一緒にパターンに無いキーを名指すスキーマは通ります。そうしたキーは無害ではありません。パターンに無いパラメータを必須にしたスキーマはすべての pathname を拒むので、そのページは決して答えません。vite build は型を検査しないので、上のエラーも tsc を走らせたときにしか出ません。

レイアウトの params は文字列として型が付く

レイアウトの params は、自分でスキーマを宣言していても文字列として型が付きます(LayoutProps でも、生成された Layout でも)。同じレイアウトは not-found.tsx のまわりでも描かれ、そこでは何も検証されないからです。ただし実行時の値はこの型のとおりではありません。ページのまわりではスタックのスキーマが出したページの値(スキーマが数値にしたなら数値)が届き、not-found.tsx のまわりでは URL の文字列がそのまま届きます。型が文字列だからといって文字列のメソッドを呼ばず、1 つの形が要るならレイアウト自身が String() で揃えるか、型の付いた値は下のページに受け取らせます。

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

export default function ProductLayout({
  params,
  children,
}: LayoutProps<'/products/:id'>) {
  return <section data-product={params.id}>{children}</section>;
}

LayoutProps<pattern> が受け取るのは、表がその位置にページを持つパターンだけです。型の制約がページのパターンだからです。自分の位置にページが無いレイアウト(ページがすべて shop/[id]/ の下にある shop/layout.tsx など)は、props をインラインで書きます。

値の一覧は要らない

パラメータの値はリクエストと一緒に届くので、前もって列挙するものはありません。カタログが変わっても再ビルドは要りません。ページは Server Component なので、値を受け取ったらそのままデータを読みます。

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

export type Product = { id: number; name: string };

const CATALOG: readonly Product[] = [
  { id: 1, name: 'first product' },
  { id: 2, name: 'second product' },
];

export const findProduct = (id: number): Product | undefined =>
  CATALOG.find((product) => product.id === id);
// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';

import { findProduct } from '../../_data/catalog.server';

export const paramsSchema = z.object({
  id: z.coerce.number().check(z.int(), z.positive()),
});

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  const product = findProduct(params.id);
  const name = product?.name ?? 'unknown product';
  return (
    <>
      <title>{name}</title>
      <h1>{name}</h1>
    </>
  );
}

スキーマは形を、ページは存在を確かめる

スキーマが受け付けた値が、データにあるとは限りません。/products/999 はスキーマを通るのでページが描かれ、ステータスは 200 です。ページにはステータスを決める API が無いので、無い商品は、ページが「見つからない」ことを描いて伝えます。スキーマは同期的なので、待つ必要のある問い合わせには使えません。