Open3

SolidStartのSSRで、ClientとServer側でCookieを同期する

yunayuna

概要

SolidStartのアプリケーションで、認証情報などをClientでセットしたあと、リロード時などにServer側でも正しく取得できるようにする必要がありました。
この仕組みは、NuxtならuseCookieのような形で、開発時に意識せずとも自然な処理ができるような仕組みがあります。
https://nuxt.com/docs/api/composables/use-cookie

これの、SolidJS版は無いので、自分で構築してみました。

実装のポイント

clientでCookieのセットを行った即時のタイミングでは、Serverへの同期は行われません。
その次にリロードなどを行い、serverへのrequest処理が行われるタイミングで、Cookie情報をrequestから読み取る形としています。(念の為server側のsessionに永続化し、Cookieから取れなかったらsessionから取る機構をいれていますが、これは不要かもしれません)

yunayuna
src/routes/api/sync-cookie.ts
import { useSession } from "vinxi/http";

export async function POST({ request }: { request: Request }) {
  "use server";
  const { key, value } = await request.json();
  const session = await useSession({ password: "secret_key_for_session_please_change_in_production_environment", name: "app_session" });
  await session.update({ ...session.data, [key]: value });
  return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
}

export async function DELETE({ request }: { request: Request }) {
  "use server";
  const { key } = await request.json();
  const session = await useSession({ password: "secret_key_for_session_please_change_in_production_environment", name: "app_session" });
  const newData = { ...session.data };
  delete newData[key];
  await session.update(newData);
  return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
} 
src/services/cookies.ts
import Cookies from "universal-cookie";
import { isServer } from "solid-js/web";
import { useSession } from "vinxi/http";
import { getRequestEvent } from "solid-js/web";

/**
 * ClientCookiesは、universal-cookieを利用
 * https://github.com/reactivestack/cookies/tree/master/packages/universal-cookie
 *
 * SSRCookiesは、cookie-universal-nuxtを利用
 * https://github.com/microcipcip/cookie-universal/tree/master/packages/cookie-universal-nuxt
 *
 * 利用できるメソッドは、各ライブラリを参照ください。
 */

const isSSR = isServer && typeof window === "undefined";

// SSR時: Cookieヘッダーから値を取得
function getCookieFromHeader(key: string): string | null {
  const event = getRequestEvent?.();
  const cookieHeader = event?.request?.headers.get("cookie");
  console.log("[cookies.ts][SSR] cookieHeader:", cookieHeader);
  if (cookieHeader) {
    const cookies = Object.fromEntries(
      cookieHeader.split(";").map((c: string) => {
        const [k, ...v] = c.trim().split("=");
        return [k, decodeURIComponent(v.join("="))];
      })
    );
    console.log("[cookies.ts][SSR] parsed cookies:", cookies);
    return cookies[key] ?? null;
  }
  return null;
}

// クライアント側
const clientCookies = new Cookies(null, {
  maxAge: __APP_CONFIG__.ACCESS_TOKEN_LIMIT,
  path: "/"
});

const getClientCookie = (key: string) => {
  const value = clientCookies.get(key);
  console.log("[cookies.ts][CLIENT] getClientCookie", key, value);
  return value;
};
const setClientCookie = (key: string, value: string) => {
  clientCookies.set(key, value, { path: "/" });
  console.log("[cookies.ts][CLIENT] setClientCookie", key, value);
  fetch("/api/sync-cookie", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key, value }),
    credentials: "include",
  });
};
const removeClientCookie = (key: string) => {
  clientCookies.remove(key, { path: "/" });
  console.log("[cookies.ts][CLIENT] removeClientCookie", key);
  fetch("/api/sync-cookie", {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key }),
    credentials: "include",
  });
};

// サーバー側
const getServerCookie = async (key: string) => {
  "use server";
  if (isSSR) {
    // SSR時はまずヘッダーから取得、なければセッションからも試みる
    const fromHeader = getCookieFromHeader(key);
    if (fromHeader !== null) {
      console.log("[cookies.ts][SSR] getServerCookie (from header)", key, fromHeader);
      return fromHeader;
    }
    // fallback: セッションから
    const session = await useSession({ password: "secret_key_for_session_please_change_in_production_environment", name: "app_session" });
    const value = session.data[key] as string || null;
    console.log("[cookies.ts][SSR] getServerCookie (from session fallback)", key, value);
    return value;
  }
  // APIルートやサーバー関数
  const session = await useSession({ password: "secret_key_for_session_please_change_in_production_environment", name: "app_session" });
  const value = session.data[key] as string || null;
  console.log("[cookies.ts][SERVER] getServerCookie (from session)", key, value);
  return value;
};
const setServerCookie = async (key: string, value: string) => {
  "use server";
  if (isSSR) return; // SSR中は何もしない
  const session = await useSession({ password: "secret_key_for_session_please_change_in_production_environment", name: "app_session" });
  await session.update({ ...session.data, [key]: value });
  console.log("[cookies.ts][SERVER] setServerCookie", key, value);
};
const removeServerCookie = async (key: string) => {
  "use server";
  if (isSSR) return; // SSR中は何もしない
  const session = await useSession({ password: "secret_key_for_session_please_change_in_production_environment", name: "app_session" });
  const newData = { ...session.data };
  delete newData[key];
  await session.update(newData);
  console.log("[cookies.ts][SERVER] removeServerCookie", key);
};

// 共通API
export const getCookieData = async (key: string) => {
  if (isServer) {
    return await getServerCookie(key);
  } else {
    return getClientCookie(key);
  }
};
export const setCookieData = async (key: string, value: string) => {
  if (isServer) {
    await setServerCookie(key, value);
  } else {
    setClientCookie(key, value);
  }
};
export const removeCookieData = async (key: string) => {
  if (isServer) {
    await removeServerCookie(key);
  } else {
    removeClientCookie(key);
  }
};