[Astro #17] Astro DB と Actions でサイバーな動的コメント欄を構築

[Astro #17] Astro DB と Actions でサイバーな動的コメント欄を構築

はじめに

前回の [Astro #16] では PWA 化によって Web サイトを「ローカルに実在するアプリ」へと進化させました。

今回はその逆、静的なサイトから「Wired(情報空間)」へ接続し、訪問者が自由にデータを残せる 動的なコメント欄(BBS) を実装した記録です。

Astro は本来静的サイトジェネレーター(SSG)ですが、Astro DB と Astro Actions を組み合わせることで、API ルートをゴリゴリ書かなくても簡単にフルスタックな機能を持たせることができます。

前回の記事:

システムの全体構成

今回の実装で使用した主要技術は以下の通りです。

  • データベース: Astro DB + Turso (SQLite 互換のエッジ DB)
  • バックエンド処理: Astro Actions
  • バリデーション: Zod
  • スタイリング: PROTOCOL.LAIN 準拠のサイバーパンク CSS
データベース: Astro DB + Turso (SQLite 互換のエッジ DB) 1 データベース: Astro DB + Turso (SQLite 互換のエッジ DB) 2 データベース: Astro DB + Turso (SQLite 互換のエッジ DB) 3

1. データベースの定義 (Astro DB)

まずはデータを保存する箱を作ります。db/config.ts にテーブルのスキーマを定義します。

// db/config.ts
import { defineDb, defineTable, column } from 'astro:db';

const Posts = defineTable({
  columns: {
    id: column.number({ primaryKey: true }),
    type: column.text({ default: 'comment' }), // 'comment' or 'bbs'
    targetId: column.text(), // 紐づく記事の slug など
    author: column.text({ default: 'Anonymous' }),
    content: column.text(),
    ipHash: column.text(), // IPアドレスをハッシュ化した識別子
    status: column.text({ default: 'published' }),
    isAdmin: column.boolean({ default: false }),
    createdAt: column.date({ default: new Date() }),
  }
});

export default defineDb({
  tables: { Posts },
});

Turso との接続設定

ローカルのモック DB ではなく、本番の Turso にデータを書き込むため、プロジェクトルートの .env に接続情報を追記します。

ASTRO_DB_REMOTE_URL=libsql://your-db-name.turso.io
ASTRO_DB_APP_TOKEN=your_turso_token_here

ローカル開発で Turso を見に行く場合は、起動コマンドに --remote を付与します。 npx astro dev --remote

2. サーバー側ロジック (Astro Actions)

次に、クライアントから送られてきたデータを受け取り、DB に保存する処理を src/actions/index.ts に記述します。

// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { db, Posts } from 'astro:db';
import crypto from 'node:crypto';

export const server = {
  postComment: defineAction({
    // ★重要: クライアントから FormData 形式で受け取るために必須
    accept: 'form',
    input: z.object({
      type: z.enum(['bbs', 'comment']),
      targetId: z.string(),
      author: z.string().max(20).default('Anonymous'),
      content: z.string().min(1).max(1000),
    }),
    handler: async (input, context) => {
      // 最低限のセキュリティ: IPアドレスをハッシュ化して識別子にする
      const ip = context.clientAddress || 'unknown';
      const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 12);

      try {
        const result = await db.insert(Posts).values({
          type: input.type,
          targetId: input.targetId,
          author: input.author,
          content: input.content,
          ipHash: ipHash,
          status: 'published',
          isAdmin: false,
        });

        return { success: true, hash: ipHash };
      } catch (e) {
        console.error("DB Insert error:", e);
        return { success: false, error: e };
      }
    }
  })
};

3. クライアント側 UI (CyberComments.astro)

記事の下部に設置するコンポーネントです。DB からのデータ取得(SSR)と、クライアントからのデータ送信(Client JS)をひとつのファイルにまとめます。

UX を考慮し、「過去のログ一覧」を上に、「入力フォーム」を下に 配置しています。

---
// src/components/CyberComments.astro
import { db, Posts, eq, desc } from 'astro:db';

interface Props {
  slug: string;
}
const { slug } = Astro.props;

// DBから対象記事のコメントを取得
const comments = await db.select()
  .from(Posts)
  .where(eq(Posts.targetId, slug))
  .orderBy(desc(Posts.createdAt));
---

<section class="cyber-comments">
  <h3><span class="glitch">COMM_LOG: {slug}</span></h3>

  <div class="comment-list" id="comment-list">
    {comments.length === 0 ? (
      <div class="comment-item empty-log">NO DATA FOUND IN THIS SECTOR.</div>
    ) : (
      comments.map((comment) => (
        <div class="comment-item">
          <div class="comment-meta">
            <span class="author">{comment.author}</span>
            <span class="hash">ID:{comment.ipHash}</span>
            <span class="date">{comment.createdAt.toLocaleDateString()}</span>
          </div>
          <p class="content">{comment.content}</p>
        </div>
      ))
    )}
  </div>

  <hr class="cyber-divider" />

  <form id="comment-form" class="comment-form">
    <input type="hidden" name="type" value="comment" />
    <input type="hidden" name="targetId" value={slug} />

    <div class="input-group">
      <input type="text" name="author" placeholder="IDENTITY" class="cyber-input" />
      <textarea name="content" placeholder="ENTER MESSAGE..." required class="cyber-input"></textarea>
    </div>

    <button type="submit" class="cyber-button">SEND_PACKET</button>
  </form>
</section>

<script>
  import { actions } from 'astro:actions';

  const form = document.getElementById('comment-form') as HTMLFormElement;
  if (form) {
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const formData = new FormData(form);

      try {
        // Actionsを呼び出してデータ送信
        const { data, error } = await actions.postComment(formData);

        if (!error && data?.success) {
          // 成功したら画面をリロードして最新のログを表示
          window.location.reload();
        } else {
          console.error("Transmission Failed:", error);
          alert("パケットの送信に失敗しました。");
        }
      } catch (err) {
        console.error("Network Error:", err);
      }
    });
  }
</script>

<style>
  /* PROTOCOL.LAIN 準拠のサイバーパンクスタイル */
  .cyber-comments {
    margin-top: 4rem;
    padding: 2rem;
    border: 1px solid rgba(0, 229, 255, 0.3);
    background: linear-gradient(rgba(0, 20, 40, 0.8), rgba(0, 10, 20, 0.9));
    font-family: monospace;
  }

  .comment-list {
    margin-bottom: 2rem;
    max-height: 500px;
    overflow-y: auto;
  }

  .comment-item {
    margin-bottom: 1.5rem;
    padding-bottom: 1rem;
    border-bottom: 1px dashed rgba(0, 229, 255, 0.2);
  }

  .comment-meta {
    display: flex;
    gap: 1rem;
    font-size: 0.8rem;
    color: #00e5ff;
    margin-bottom: 0.5rem;
  }

  .hash { color: #ff0055; }
  .content { color: #ccc; margin: 0; line-height: 1.5; }
  .cyber-divider { border: 0; border-top: 1px dashed rgba(0, 229, 255, 0.2); margin: 2rem 0; }

  .cyber-input {
    width: 100%;
    background: rgba(0, 0, 0, 0.5);
    border: 1px solid rgba(0, 229, 255, 0.3);
    color: #00e5ff;
    padding: 0.8rem;
    margin-bottom: 1rem;
  }

  .cyber-button {
    background: #00e5ff;
    color: #000;
    border: none;
    padding: 0.5rem 1.5rem;
    cursor: pointer;
    font-weight: bold;
  }
  .cyber-button:hover { background: #fff; }
</style>

遭遇したトラブルと解決策

今回の実装では、データの送受信周りでいくつか躓きポイントがありました。

トラブル 1: フォームが GET メソッドで送信されてしまう

<form> タグに method="POST" を付け忘れたり、JavaScript 側での e.preventDefault() が正しく発火しなかったりすると、ブラウザ標準の GET 送信が走り、URL の末尾にクエリパラメータ(?author=...&content=...)が丸見えになってしまいます。 対策: JS で送信制御を行うため、HTML の <form> には method を指定せず、必ず e.preventDefault() で標準挙動をキャンセルします。

トラブル 2: 504 Gateway Timeout

「サーバーにはアクセスしているのに 10ms 等で処理が空振りする」現象です。 対策: Astro Actions は動いていても、接続先の DB (Turso) のトークンが .env に設定されていなかったり、起動コマンドに --remote が付与されていないことが原因でした。

データベース: Astro DB + Turso (SQLite 互換のエッジ DB) トラブル 2: 504 Gateway Timeout

トラブル 3: 415 Unsupported Media Type

window.location.reload() などを実装し、いざ送信した際にブラウザのコンソールに 415 エラーが表示されました。 対策: actions.postComment(formData) のように FormData を直接サーバーに投げる場合、actions/index.ts のアクション定義内に accept: 'form' を追記して、Astro 側に「JSONではなくフォームデータを受け取る」ことを明示する必要がありました。

まとめ

これでただの静的サイトから、Wired(ネットワーク)を通じて外部の人間が干渉できるシステムになりました。Astro DB と Turso の組み合わせはセットアップが非常に軽量で、フロントエンドの枠に収まらない機能をサクッと追加できるのが魅力です。


COMM_LOG: astro-17-db-actions-cyber-comments

NO DATA FOUND IN THIS SECTOR.