サーバーが読む設定をCookieに置く
localStorageに置いた好みはサーバーに届かないので、サーバーは既定値で描き、ハイドレーションのあとで本当の値に切り替わります。表示密度のように、その切り替わりが目に見える好みはCookieに置きます。Cookieはリクエストのたびに届くので、サーバーが最初から本当の値で描けます。
このページの内容
Cookieの状態を定義する
defineCookieStateに、キーとスキーマを渡します。書き方はdefineLocalStateと同じで、スキーマは自分が出した値を受け付けなければなりません。
state.tsimport { defineCookieState } from '@k8ordo/state';
import * as z from 'zod';
export const density = defineCookieState(
'density',
z.object({
density: z
.enum(['comfortable', 'compact'])
.default('comfortable'),
}),
);値はk8ordo-state.densityという1つのCookieに、スキーマに書いたフィールドのJSONとして保存されます。この名前は、定義のcookieNameで読めます。区切りが:ではなく.なのは、Cookieの名前に:を使えないからです。
同じ理由で、キーに使えるのは英数字と、HTTPのtokenに入る一部の記号だけです。空白や:、;を含むキーは、モジュールの読み込みで拒まれます。
サーバーで読む
@k8ordo/serverでは、ページとレイアウトがリクエストをrequestとして受け取ります。そのrequest.cookiesをparseCookiesに渡すと、スキーマを通した値が返ります。
routes/layout.tsximport type { LayoutProps } from '@k8ordo/router';
import { density } from '../state';
import { Shell } from './shell';
export default function Layout({
request,
children,
}: LayoutProps<'/'>) {
return (
<Shell initialCookie={density.parseCookies(request.cookies)}>
{children}
</Shell>
);
}routes/shell.tsx'use client';
import { useAppState } from '@k8ordo/state';
import type { OutputOf } from '@k8ordo/state';
import type { ReactNode } from 'react';
import { density } from '../state';
type Props = {
initialCookie: OutputOf<typeof density.schema>;
children: ReactNode;
};
export function Shell({ initialCookie, children }: Props) {
const [values] = useAppState(density, { initialCookie });
return <div data-density={values.density}>{children}</div>;
}parseCookiesが受け取るのは、値をパーセントデコードしたReadonlyMap<string, string>です。request.cookiesはこの形なので、そのまま渡せます。Cookieが無いときやJSONが壊れているとき、スキーマに合わないときは、フィールドごとに既定値に戻ります。
読んだ値は、initialCookieとしてuseAppStateに渡します。サーバーの描画とハイドレーションの描画がその値で行われるので、既定値がちらつきません。
読んだ値を渡す範囲
initialCookieが効くのは、それを渡したuseAppStateだけです。
サーバーで描かれるのにinitialCookieを受け取っていないコンポーネントは、サーバーでは既定値を描きます。そのため、レイアウトのような上の方で一度だけ読み、下へ渡してください。
@k8ordo/staticにはリクエストがありません。サーバーの描画は既定値で行われ、ハイドレーションのあとでCookieの値に切り替わります。localStorageと同じ振る舞いです。
ブラウザが書くCookie
update()は、Cookie Store APIでCookieを書きます。付ける属性は次のとおりで、書き込むたびに付け直します。
Path=/:サイトのどのパスへのリクエストにも付きます。SameSite=Lax:ほかのサイトのリンクから来た最初のリクエストにも付きます。APIの既定のStrictでは、まさにそのリクエストでサーバーが既定値を描いてしまいます。Max-Age:400日です。ブラウザがCookieを保つ上限で、書くたびに延びます。Secure:APIが必ず付けるので、ページはHTTPSで配信します。ChromiumとFirefoxはhttp://localhostでも保ちますが、Safariはそこでも捨てます。Safariで確かめるなら、開発中もHTTPSで配信してください。
読むときは、document.cookieから同期的に読みます。描画の途中ではAPIのPromiseを待てないからです。ほかのタブの書き込みや、サーバーの応答が設定したCookieは、APIのchangeイベントで届きます。
Cookieはリクエストのたびに送られるので、小さく保ってください。名前と値を合わせて4KBを超えるCookieは拒まれます。そのときはupdate()のハンドルがAPIのTypeErrorでrejectし、描画された値はそのまま残ります。
サーバーから同じCookieを書く
ページは描画するだけで、応答にCookieを書きません。Cookieを書くのは、リクエストに答える場所です。@k8ordo/serverでは、guard.tsとroute.ts、Server Actionがフレームワークのcookies()で書きます。
JavaScriptが無くても好みを変えられるフォームのように、Cookieの状態をサーバーから書きたいこともあります。そのときは、名前にcookieNameを、値にcookieValue()の結果を渡します。cookieValue()は値をスキーマに通し、指定しなかったフィールドを既定値で埋めます。
actions.ts'use server';
import { cookies } from '@k8ordo/server/runtime';
import { density } from './state';
export async function compact() {
cookies().set(
density.cookieName,
density.cookieValue({ density: 'compact' }),
{ httpOnly: false, maxAge: 34_560_000 },
);
}httpOnly: falseは外さないでください。HttpOnlyのCookieはスクリプトから見えないので、ブラウザのストアが読めなくなります。cookies()の既定はPath=/とSameSite=Laxなので、そのほかの属性はブラウザが書くものとそろいます。開いているほかのタブには、changeイベントで新しい値が届きます。
cookieValue()は、エンコードしていないJSONを返します。cookies().setが書き出すときにパーセントエンコードし、parseCookiesはそれを戻した値を受け取るからです。Set-Cookieヘッダーを自分で書くときは、encodeURIComponentを1回だけ通してください。
秘密は置かない
ブラウザが書くCookieは、HttpOnlyにできません。
落とし穴
ページ上のどのスクリプトもこのCookieを読み書きでき、訪問者もURLと同じように書き換えられます。セッションやトークンのように、漏れたり偽造されたりすると困るものは置かないでください。そうしたCookieはcookies()でHttpOnlyとして書き、このパッケージには触らせません。
サーバーで読んだ値も、信頼できる状態ではなく入力として扱ってください。parseCookiesがスキーマを通してから値を返すのは、そのためです。