Open14

ブログを作るぞ(n 回目)

KeiKei

作るぞ。
とりあえず構成はこんな感じでいくぞ。

フレームワーク: Astro
コンポーネント: React, Ark UI
管理画面の認証: better-auth
DB ORM: Kysely
プラットフォーム: Cloudflare

KeiKei

認証に better-auth を使うことにしたぞ。

better-auth 選定理由

  1. 使ったことないから。
KeiKei

ORM は Kysely を使うぞ。

Kysely 選定理由

  1. better-auth のデフォルト ORM がこれみたいなので。
  2. 使ったことない。(Prisma と drizzle はある)
KeiKei

Astro 選定理由

  1. 今まで使ったことないから興味で。
  2. 静的サイトなので。

完。
ちなみに JetBrains の Fleet で開発しようと思ったら .astro ファイルに対応してなかった。ぴえん。

KeiKei

Ark UI 選定理由

  1. 今まで使ったことないから興味で。
  2. シンプルなブログだからこそアクセシビリティに気を使いたい。
  3. カテゴリ欄で Tree View 的なレイアウトを使いたいのでそれに対応しているやつを選んだ。

他の候補は React Aria Component(使ったことあるので今回はパス)、Base UI(Tree View コンポーネントがないのでパス)、Ariakit(Tree View ない)。
足りないものがあればある程度自作も交えて頑張るけど、場合によっては他のに変えるかも。

KeiKei

Cloudflare

これに関しては使い慣れてるやつを選んだ。
有名どころのライバルって Vercel と Supabase とかかね。どちらも使ったことある。

KeiKei

というわけで Astro と Ark UI に挑戦する記録をここに残していくぞ。
あとブログの UI が完成したらオリジナル CMS を作りたいぞ。はたしてぶん投げずに完成させられるかどうか。

KeiKei

better-auth と D1 を連携させる時にさっそく詰まりかけた。
このライブラリを使おうとして、サンプルコードの通り env.DB からデータベース情報を取得しようとしたら env is undefined になった。
型だけ手動で設定したら自動で global に生やしてくれると思っていたがどうやら違うらしい。
https://github.com/aidenwallis/kysely-d1?tab=readme-ov-file

調べたら import.meta.env から Env のインターフェイスにアクセスできる模様。これで解決。

KeiKei

違った。
この記事によれば、必要な時に動的に better-auth のサーバ側クライアント(ややこしい)を作成する必要があるらしい。
https://zenn.dev/tnakamoto/articles/771b0f3af050d8

というわけでこんな感じにした。

src/auth.ts
export function createServerAuth({ DB }: Env) {
  return betterAuth({
    database: {
      dialect: new D1Dialect({ database: DB }),
      type: "sqlite"
    }
  })
}
pages/api/auth/[...all].ts
export const ALL: APIRoute = async (ctx) => {
  const auth = createServerAuth(ctx.locals.runtime.env)

  return auth.handler(ctx.request)
}
env.d.ts
type Runtime = import('@astrojs/cloudflare').Runtime<Env>;

declare namespace App {
  interface Locals extends Runtime {}
}
KeiKei

↑の影響?で npx @better-auth/cli generate が使えない。
仕方ないので自分でテーブルを作る。
npm wrangler d1 migrations create <DATABASE_NAME> <MIGRATION_NAME> して作成されたファイルに以下のコードを書き込み、npm wrangler d1 migrations apply <DATABASE_NAME> で適用する。

-- better-auth schema
-- https://www.better-auth.com/docs/concepts/database#core-schema
CREATE TABLE user (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  emailVerified BOOLEAN DEFAULT FALSE,
  image TEXT,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE session (
  id TEXT PRIMARY KEY,
  userId TEXT NOT NULL,
  token TEXT NOT NULL UNIQUE,
  expiresAt TIMESTAMP NOT NULL,
  ipAddress TEXT,
  userAgent TEXT,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
);

CREATE TABLE account (
  id TEXT PRIMARY KEY,
  userId TEXT NOT NULL,
  accountId TEXT NOT NULL,
  providerId TEXT NOT NULL,
  accessToken TEXT,
  refreshToken TEXT,
  accessTokenExpiresAt TIMESTAMP,
  refreshTokenExpiresAt TIMESTAMP,
  scope TEXT,
  idToken TEXT,
  password TEXT,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE,
  UNIQUE(providerId, accountId)
);

CREATE TABLE verification (
  id TEXT PRIMARY KEY,
  identifier TEXT NOT NULL,
  value TEXT NOT NULL,
  expiresAt TIMESTAMP NOT NULL,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
KeiKei

今度は Uncaught ReferenceError: MessageChannel is not defined が出る。
この issue のコメントを参考に astro.config.mjs に追記すれば OK。
ただ、コメントのコードをそのままコピペすると型エラーが出るので && から三項演算子に書き換えた。
https://github.com/withastro/astro/issues/12824#issuecomment-2563095382

astro.config.mjs
export default defineConfig({
  ...
  vite: {
    resolve: {
      // Use react-dom/server.edge instead of react-dom/server.browser for React 19.
      // Without this, MessageChannel from node:worker_threads needs to be polyfilled.
      alias: import.meta.env.PROD? { "react-dom/server": "react-dom/server.edge" } : undefined,
    }
  }
})
KeiKei

Astro の action と React の useActionState の組み合わせが便利

便利。
Astro の action を使うメリット: Zod が最初から用意されているおかげでバリデーションライブラリ不要・クライアントのコードを最小限に抑えられる
React の useActionState を使うメリット: フォーム系ライブラリ不要

Astro の action 内でリダイレクトさせることはできないみたいなので、こんな感じで使ってみてる。

import { type ActionError, actions } from "astro:actions"
import { navigate } from "astro:transitions/client"
import { useActionState } from "react"

async function loginFormAction(_: ActionError | null, formData: FormData) {
  const { error } = actions.login(formData)

  if (error) return error

  await navigate("...")

  return null
}

export default function LoginForm() {
  const [ error, formAction ] = useActionState(loginFormAction, null)

  return (
    <form action={formAction}>
      ...
    </form>
  )
}
KeiKei

Astro の action で better-auth を使う

基本的には普通に使えるんだけど、1つだけ「action では Response を返せない」という問題がある。
その為 better-auth から返された Headers を元に Astro の cookies に追加する必要がある。
Headers から Set-Cookie を読み込むには getSetCookie() を使うと便利。けどもちろんただの string[] で返されるので、自分で cookies.set() に渡せるような形に変換しなくてはならない。
それには Remix/React Router の開発メンバーの方が開発している @mjackson/headers から SetCookie を import して使うとこれまた便利。

import { defineAction } from "astro:actions"
import { SetCookie } from "@mjackson/headers"

// cookies.set() した時に自動でエンコードされて値が変わってしまうので、
// 何も変化させないようにする
const encode = (value: string) => value

export const server = {
  login: defineAction({
    input: ...,
    async handler(input, context) {
      const { headers } = await auth.api.signInEmail({
        body: input,
        returnHeaders: true
      })
      const setCookies = headers.getSetCookie()

      setCookies.forEach(setCookie => {
        const { name, value, sameSite, ...options } = new SetCookie(setCookie)
        // AstroCookie の sameSite と @mjackson/headers SetCookie の sameSite の型は少し違うので合わせる為に変換する
        // AstroCookie's sameSite: boolean | 'lax' | 'none' | 'strict'
        // SetCookie's sameSite: 'Strict' | 'Lax' | 'None'
        const lowerCaseSameSite = sameSite?.toLowerCase() as LowerCase<NonNullable<typeof sameSite>> | undefined

        // どちらも string? なので型エラーを消す為にも確認しておく
        if (!name || !value) return

        // 他の options はそのまま渡して OK, encode を忘れずに
        context.cookies.set(name, value, { sameSite: lowerCaseSameSite, encode, ...options })
      })

      return { success: true }
    }
  })
}
KeiKei

エラーについて

Astro の action ではエラーを返したい時 ActionError として throw すると良い感じにクライアントで使いやすいようにしてくれる。
そして、better-auth は auth.api.something() でエラーが起きた際には認証エラーの情報を含んだ APIError という専用のエラーを throw している。
つまりこれを上手く ActionError に変換してやる必要がある。

ActionError に必要なのは codemessage のみなのだが、この code型が設定されている
APIError の方は APIError.status から ActionError.code 相当の値を取得できるが、APIError.status には "OK" や "ACCEPTED" のようなエラー以外のものや、"I'M_A_TEAPOT" まで含まれているのでそのまま渡すことはできない。
ありがたいことに Astro は ACTION_ERROR_CODES という code で受け入れることのできる値が入った配列を export してくれているので、これを使って絞り込もう。

import { ActionError, type ActionErrorCode, ACTION_ERROR_CODES, defineAction } from "astro:actions"
import { APIError } from "better-auth/api"

// このままでは ReadonlyArray<ActionErrorCode>.includes(string) がエラーになるので型を弱める
const actionErrorCodes: ReadonlyArray<string> = ACTION_ERROR_CODES

function isAstroActionErrorCode(statusCode: unknown): statusCode is ActionErrorCode {
  return typeof statusCode === "string" && actionErrorCodes.includes(statusCode)
}

function handleAuthActionError(e: unknown) {
  if (e instanceof APIError && isAstroActionErrorCode(e.status) && e.status !== "INTERNAL_SERVER_ERROR" ) {
    return new ActionError({
      code: e.status,
      message: e.message
    })
  } else {
    console.error(`Unknown Action Error: ${e}`)

    return new ActionError({
      code: "INTERNAL_SERVER_ERROR",
      message: e instanceof Error? e.message : "internal server error"
    })
  }
}

export const server = {
  login: defineAction({
    input: ...,
    async handler(input, context) {
      try {
        ...
      } catch (e) {
        throw handleAuthActionError(e)
      }
    }
  })
}