Cloudflare でやりたいが、Pages Function はちょっと非推奨っぽいので Workers でやる
Astro はもはやフルスタック Web フレームワークなので、 API Route 的なものを実装してフルスタックなWebフレームワークも作れる。
https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes
また、 @astrojs/cloudflare
を使うとCloudflare Pages Function で SSR できる。
…が、最近は Workers でも静的アセット配信ができて、 Pages から移行が推奨されている。
https://cai.im/blog/migrate-astro-site-from-cloudflare-pages-to-workers/
そういう風潮の中でわざわざ Pages Function を使うのもアレなので、なんとか Workers と静的アセットでやってみたい
静的サイト + Island Architecture + Workers API
要するに、ブログの いいねボタン
コンポーネントだけが動的な振る舞いを必要としている。
であれば、そこ以外は SSG して必要な部分だけ JS をハイドレーションするという、奇しくも Astro の提唱するスタイルがうまく合致することになる。
サーバーコードをどうするか。
Web 業界は Next.js や Remix による SSR に回帰しつつあり、バックエンドとフロントエンドの境界がないことも増えた。
しかし、今自分がやろうとしていることはここ最近まで主流だったサーバーとクライアントの完全な分離アーキテクチャである。(3層アーキテクチャとでも呼ぶべきか)
ただ、バックエンドのコードはごく少量であり、わざわざ pnpm ワークスペースを作ったりして分離するのもオーバーな気がする。 コード量的に主従の逆転感はあれど、Cloudflare Worker で実行するコード と 静的アセット のデプロイ自体は Wrangler CLI を通して同時にするので、同じディレクトリで開発したほうがいい。
そこで、いっそのことフロントのリポジトリに src/workers/main.ts
を作って、パッケージを分けずに同居させてしまうことにした。
フロント/バックの境界を超えないことを肝に銘じつつ、フロント大多数のコードベースに少量のバックエンドを混ぜる。
pnpm add -D wrangler
name = "harumaxy-com"compatibility_date = "2025-04-01"main = "src/workers/main.ts" # wrangler でビルドされる、Workers API のエントリーポイント
[assets]directory = "./dist/" # `astro build` で出力した静的アセットファイルのディレクトリ
Astro によるビルドと Wrangler CLI によるビルドは独立している。
Astro は中身では vite
や esbuild
を使用しており、 import していないコードに関しては tree shaking されてバンドルされないはず…
永続化の用意
いいねを永続化するのに Cloudflare D1
を使う。
全いいね数を集計して表示したいので、標準SQLで count()
などの集約関数が使えるため Workers KV より適任
Wrangler CLI から作成
pnpm create d1 create <db-name>
[[d1_databases]]binding = "DB"database_name = "harumaxy-com-db"database_id = "****-****-****-****-****"
大したことに使わないので、マイグレーションツールを使うでもなく DDL = データ定義SQL を直接実行してテーブルを作成した。 (あとでスキーマ変えたくなったら…とかは考えない。シンプルなことにしか使わないので)
CREATE TABLE likes ( slug TEXT NOT NULL, user_uuid TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE (slug, user_uuid));
CREATE INDEX idx_likes_user_uuid ON likes(user_uuid);
Cloudflare ダッシュボード > ストレージとデータベース > D1 SQL データベース > コンソール
から直接SQLを打ち込んで本番環境に反映した。
このコンソールは本番DBの中身をチェックしたり、ちょっとした手動マイグレーションを行うのに便利。
API の実装
以下3つのAPIが必要だった。
GET /api/likes/:slug
記事のいいね数(と自分がいいねしたか) の取得POST /api/likes/:slug
いいねをつけるDELETE /api/likes/:slug
いいねを外す
(もしかしたら、つける & 外す
1つのAPIにまとめてトグル挙動でいいのかもしれないけど、REST API はべき等性が重要らしいので…)
Workers で RESTful API を生で書こうとすると if 文地獄になるので、 router を持ったサーバーフレームワークを使うのが楽。
Hono で実装してみた。
Cloudflare Workers では、 Request を受取り Response を返す fetch インターフェース
に互換性のあるサーバーフレームワークであれば割と簡単に統合できる。(Node.js API などに依存しておらず Web 標準に準拠していること)
コード: https://github.com/harumaxy/harumaxy.com/blob/main/src/workers/main.ts
さらに、サーバーアプリの型を export することで Hono RPC により E2E で型安全な呼び出しができる。
フロントとサーバーの境界を超えて type import しているが、型はトランスパイルで消えるので大丈夫。
無駄なコードがバンドルされることはない。
ビルドされた静的アセットの確認
一応無駄なコードが入ってないかも確認できる。
Astro は vite
(あるいは rollup
) ベースなので、プラグインで dist/stats.html
を出力して確認。
pnpm add -D rollup-plugin-visualizer
export default defineConfig({ // ... vite: { plugins: [ visualizer({ emitFile: true, filename: "stats.html", }), ],})
いいねボタンの実装
拝借した Astro テーマの Fuwari が Svelte ベースでコードが書かれているので、余計な依存を増やさないため自分も Svelte で書く。
<script lang="ts"> import { hc } from "hono/client"; import { onMount } from "svelte"; import type { countLikes, ServerType } from "@/workers/main";
type Likes = Awaited<ReturnType<typeof countLikes>>; const client = hc<ServerType>(window.location.origin);
// Props, States const { slug } = $props<{ slug: string }>();
let userUuid = $state<string | null>(null); let likes = $state<Likes | null>(null);
// Callback const fetchLikes = async (likedRecently: boolean) => {...}; const saveLikedRecently = () => {...}; const loadLikedRecently = () => {...}; const like = async () => {...}; const unlike = async () => {...};
let handleClick = $derived(() => { if (likes?.likedByMe) { unlike(); } else { like(); } });
onMount(() => { const storedUserId = localStorage.getItem("userId"); if (storedUserId) { userUuid = storedUserId; } else { userUuid = crypto.randomUUID(); localStorage.setItem("userId", userUuid); } fetchLikes(loadLikedRecently()); });</script>
<div class="bg-white dark:bg-gray-800 rounded-full shadow-lg border border-gray-200 dark:border-gray-700 p-3 min-w-[60px] text-center"> <button class="flex items-center justify-center gap-2 w-full text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-red-500 dark:hover:text-red-400 transition-colors" onclick={handleClick} > <svg class="w-5 h-5 {likes?.likedByMe ? 'fill-red-500 text-red-500' : 'fill-none'}"/>...</svg> {likes?.count ?? ""} </button></div>
このコンポーネントのエフェクト
- マウントされたら、リモートAPIにいいね数を取りに行き、数を表示する
- クリックしたら、
既にいいねした
かどうかに基づき いいねする or いいねを外す
ちなみに、ユーザー認証はテキトーです。
ログインなしで気軽に利用できるようにしたかったので。
userId の発行は、 localStorage を見てあればそれを使う、なければ発行して localStorage に永続化って感じで。
多分、localStorage を消したり PC・スマホ・ブラウザを変えたら何度もいいねできるけどそこは気にしない。
(きっちりやろうと思えばできるけど、場末の個人ブログで認証ログインなんて求めたら足ブラウザバックされるので…)
投稿一覧ページでの使用
実は、あんまり効率の良い実装ではありません…
投稿一覧ページでもメタ情報としていいね数を表示したかったので流用したのですが、いいねボタンコンポーネント自体が状態とエフェクトを持っているので、 1 + N
回のAPIアクセスが発生しています。
これを回避するには、 投稿一覧ページ全体のハイドレーション
+ いいね数のバッチ取得API
が必要です。
呼び出し効率化のためには必要ですが、ちょっとめんどい… (一覧表示の効率化のための特殊化コードが必要)
まあF5連打とかで叩かれると Workers 呼び出し回数が跳ね上がってしまうので、キャッシュでなんとかすることにしました。
// いいね数取得の処理const likesApp = new Hono<{ Bindings: Bindings }>() .get(":slug", pathValidator, queryValidator, async (c) => { const { slug } = c.req.valid("param"); const { userUuid } = c.req.valid("query"); const likes = await countLikes(c.env.DB, { slug, userUuid }); // ブラウザで30分キャッシュする (経路キャッシュも狙う) c.header("Cache-Control", "public, max-age=1800, s-maxage=1800"); return c.json(likes); })
const fetchLikes = async (likedRecently: boolean) => { if (!userUuid) return null; const res = await fetch(`/api/likes/${slug}?userUuid=${userUuid}`, { method: "GET", // sessionStorage に手動管理の期限付きで、最近いいねした場合は reload、そうでなければ cache があれば使う cache: likedRecently ? "reload" : "default", headers: { "Content-Type": "application/json" }, }); likes = await (res.json() as Promise<Likes>); };
キャッシュが有効になってると、リモートの変更が反映されずバグっぽく見えてしまう可能性があるので、いいねボタンを押した直後 ~ 30 秒くらいの間だけキャッシュを無視して fetch するようなクライアント実装にしました。 (おそらく、React Query 的な something を使ったほうがいい)
この実装だと、一覧ページと詳細ページで GET /api/likes/:slug
からのレスポンスキャッシュを利用できるので地味に再利用できている。
(最初の N + 1
クエリは避けれてないけど…)
まあ、いいね数取得は遅延ロードだし、ページの他の静的な部分は高速表示されてるはずなので、そこまでユーザービリティを損ねないと思います。
s-maxage
による経路キャッシュも狙ってますが、そもそもエッジで実行されてるし、読み取りが早い D1 なのでいらんかもしれない。
実装内容
だいたいこんな感じ