Cloudflare Pages + Hono + D1で構築するエッジネイティブAPI基盤:実装から自動化まで

|
Cloudflare Hono D1 Edge TypeScript API Vite Automation Build

Cloudflare Pages + Hono + D1でエッジネイティブなAPI基盤を構築する完全ガイド。技術選定の根拠・実装手順・Markdown Auto-Discoveryによるブログ自動化・CI/CDまでを一冊にまとめた実践ガイド。

はじめに:「サーバーレスの次」を実装する

Lambda + API Gateway の組み合わせは2015年以降のスタンダードだった。しかし2025〜2026年の視点では、より根本的なトレードオフを持つ選択肢が実用段階に達している。

問題はコールドスタートだけではない。「リクエストのたびに認証・ルーティング・DB接続を最初から実行する」設計そのものの非効率さだ。V8 Isolate ベースの Edge Runtime はこのモデルを覆す。

サーバーレス世代の比較:

  第1世代: Lambda + API Gateway(2015〜)
  ─────────────────────────────────────
  長所: イベント駆動、自動スケール、サーバー管理不要
  短所: コールドスタート 200ms〜1000ms
        API Gateway の設定が複雑
        リクエスト数課金(月100万リク以降 $3.5/100万)
        リージョンが固定(例:ap-northeast-1のみ)

  第2世代: Edge Runtime / V8 Isolate(2022〜実用化)
  ─────────────────────────────────────
  長所: コールドスタート 0ms(Isolateの事前ウォームアップ)
        Cloudflare の 300+ PoP でグローバル配信
        Workers Free: 月1,000万リクまで無料
        デプロイが git push のみ
  短所: Node.js APIが使えない(Web API のみ)
        CPU時間 50ms 上限
        ステートフルな処理に向かない

この記事は2部構成になっている。

  • Part 1〜7 ── Cloudflare Pages + Hono + D1 による REST API 基盤の設計・実装・CI/CD
  • Part 8〜12 ── Vite バーチャルモジュールを使った Markdown Auto-Discovery(ブログ記事の自動インデックス生成)

Part 1:技術選定の根拠

なぜ Hono か

フレームワーク比較:

  Express(Node.js専用)
    → Workers では動かない(Node.js APIに依存)

  Fastify(Node.js専用)
    → 同上

  Hono(Web Standards ベース)
    → Cloudflare Workers / Deno / Bun / Node.js すべてで動く
    → Web API(Request / Response / Headers)のみ使用
    → バンドルサイズ 13KB(Express の 1/50)
    → TypeScript ファースト、型推論が強力
    → RPC モード:フロントエンドから型安全なAPIコール
    → Zod バリデーターとの公式インテグレーション

Hono の本質は「Web Standards への忠実な実装」だ。c.reqRequest オブジェクト、c.json()Response を返す。Node.js固有の req.bodyres.send() は登場しない。

// Hono が Web Standard API のみ使う例
app.post('/api/items', async (c) => {
  // c.req.json() は Request.json() と等価
  const body = await c.req.json()
  // c.json() は new Response(JSON.stringify(...)) と等価
  return c.json({ created: true }, 201)
})

なぜ D1 か

エッジからのDB接続比較:

  PlanetScale / Neon(リモートDB)
    → HTTP経由のラウンドトリップが毎クエリ発生
    → エッジ → リモートDB のレイテンシ: 50〜200ms

  D1(SQLite at the Edge)
    → Workers と同じPoP で SQLite が動く
    → クエリのレイテンシ: 1〜10ms
    → スキーマ・マイグレーションが使える
    → 無料枠: 5GB ストレージ / 日500万行読み取り

  適合するユースケース:
    ✅ コンテンツAPI(読み取り多・書き込み少)
    ✅ 設定・プロファイルの参照
    ✅ グローバルに低レイテンシを提供したいDB
    ❌ 高頻度書き込み(SNSのタイムライン等)
    ❌ ACID トランザクションを多用する金融系

D1 は「リモートDBをエッジから叩く」のではなく、SQLite がエッジで直接動く。この違いはレイテンシだけでなく、設計の考え方も変える。


Part 2:プロジェクト構成とセットアップ

ディレクトリ構成

my-api/
├── src/
│   ├── index.ts              # Honoアプリのエントリーポイント
│   ├── routes/
│   │   ├── items.ts          # /api/items
│   │   └── users.ts          # /api/users
│   ├── middleware/
│   │   ├── auth.ts           # JWT検証ミドルウェア
│   │   └── ratelimit.ts      # KV ベースのレートリミット
│   └── types.ts              # 型定義(Bindings等)
├── migrations/
│   ├── 0001_create_items.sql
│   └── 0002_create_users.sql
├── .github/
│   └── workflows/
│       └── deploy.yml        # GitHub Actions CI/CD
├── wrangler.jsonc
├── vite.config.ts
└── package.json

wrangler.jsonc の設定

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-api",
  "compatibility_date": "2025-09-01",
  "compatibility_flags": ["nodejs_compat"],
  "pages_build_output_dir": "./dist",

  // D1 データベース(本番のみ定義。ローカルは --local で自動SQLite)
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-api-production",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ],

  // KV(レートリミット・セッションキャッシュ用)
  "kv_namespaces": [
    {
      "binding": "KV",
      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  ],

  // R2(ファイルアップロード用)
  "r2_buckets": [
    {
      "binding": "R2",
      "bucket_name": "my-api-uploads"
    }
  ]
}

型定義

// src/types.ts

export type Bindings = {
  DB: D1Database
  KV: KVNamespace
  R2: R2Bucket
  JWT_SECRET: string        // wrangler pages secret put JWT_SECRET
  ALLOWED_ORIGINS: string   // 許可オリジンのカンマ区切りリスト
}

export type Variables = {
  // 認証ミドルウェアがリクエストスコープで設定する変数
  userId: string
  userEmail: string
}

Part 3:Hono アプリケーションの実装

エントリーポイントとルーター設計

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { itemsRouter } from './routes/items'
import { usersRouter } from './routes/users'
import { authMiddleware } from './middleware/auth'
import { rateLimitMiddleware } from './middleware/ratelimit'
import { type Bindings, type Variables } from './types'

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()

// ── グローバルミドルウェア ─────────────────────────
app.use('*', logger())
app.use('*', secureHeaders())

// CORS: 環境変数から許可オリジンを動的に読む
app.use('/api/*', async (c, next) => {
  const corsMiddleware = cors({
    origin: (origin) => {
      const allowed = c.env.ALLOWED_ORIGINS.split(',')
      return allowed.includes(origin) ? origin : null
    },
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400,
  })
  return corsMiddleware(c, next)
})

// レートリミット(KVベース)
app.use('/api/*', rateLimitMiddleware)

// ── 認証不要のエンドポイント ──────────────────────
app.get('/api/health', (c) => {
  return c.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
  })
})

// ── 認証が必要なルート ────────────────────────────
const protectedApp = app.use('/api/*', authMiddleware)
protectedApp.route('/api/items', itemsRouter)
protectedApp.route('/api/users', usersRouter)

// ── エラーハンドラ ────────────────────────────────
app.notFound((c) => c.json({ error: 'Not Found' }, 404))
app.onError((err, c) => {
  console.error(err)
  return c.json({ error: 'Internal Server Error' }, 500)
})

export default app

JWT 認証ミドルウェア

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import { verify } from 'hono/jwt'
import { type Bindings, type Variables } from '../types'

export const authMiddleware = createMiddleware<{
  Bindings: Bindings
  Variables: Variables
}>(async (c, next) => {
  const authHeader = c.req.header('Authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const token = authHeader.slice(7)

  try {
    // hono/jwt は Web Crypto API を使うため、Node.js の crypto 不要
    const payload = await verify(token, c.env.JWT_SECRET)
    c.set('userId', payload.sub as string)
    c.set('userEmail', payload.email as string)
    return next()
  } catch {
    return c.json({ error: 'Invalid token' }, 401)
  }
})

D1 を使ったCRUD実装

// src/routes/items.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { type Bindings, type Variables } from '../types'

export const itemsRouter = new Hono<{
  Bindings: Bindings
  Variables: Variables
}>()

// バリデーションスキーマ(入出力の型定義を兼ねる)
const createItemSchema = z.object({
  title:       z.string().min(1).max(200),
  description: z.string().max(2000).optional(),
  status:      z.enum(['draft', 'published']).default('draft'),
})

const updateItemSchema = createItemSchema.partial()

// ── GET /api/items ────────────────────────────────
itemsRouter.get('/', async (c) => {
  const { page = '1', limit = '20', status } = c.req.query()
  const offset = (parseInt(page) - 1) * parseInt(limit)

  // プリペアドステートメントで SQL インジェクション対策
  let query = 'SELECT * FROM items'
  const params: unknown[] = []

  if (status) {
    query += ' WHERE status = ?'
    params.push(status)
  }

  query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
  params.push(parseInt(limit), offset)

  const { results } = await c.env.DB.prepare(query).bind(...params).all()

  const countQuery = status
    ? 'SELECT COUNT(*) as total FROM items WHERE status = ?'
    : 'SELECT COUNT(*) as total FROM items'
  const countResult = status
    ? await c.env.DB.prepare(countQuery).bind(status).first<{ total: number }>()
    : await c.env.DB.prepare(countQuery).first<{ total: number }>()

  return c.json({
    items: results,
    pagination: {
      page:  parseInt(page),
      limit: parseInt(limit),
      total: countResult?.total ?? 0,
    },
  })
})

// ── POST /api/items ───────────────────────────────
itemsRouter.post(
  '/',
  zValidator('json', createItemSchema),   // バリデーション失敗で自動400
  async (c) => {
    const data = c.req.valid('json')
    const id   = crypto.randomUUID()      // Web Crypto API(Node.js不要)
    const now  = new Date().toISOString()

    await c.env.DB.prepare(
      'INSERT INTO items (id, title, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
    ).bind(id, data.title, data.description ?? null, data.status, now, now).run()

    return c.json({ id, ...data, created_at: now, updated_at: now }, 201)
  }
)

// ── PUT /api/items/:id ────────────────────────────
itemsRouter.put(
  '/:id',
  zValidator('json', updateItemSchema),
  async (c) => {
    const id   = c.req.param('id')
    const data = c.req.valid('json')

    const existing = await c.env.DB
      .prepare('SELECT id FROM items WHERE id = ?')
      .bind(id).first()
    if (!existing) return c.json({ error: 'Item not found' }, 404)

    const updates = Object.entries(data)
      .filter(([, v]) => v !== undefined)
      .map(([k]) => `${k} = ?`)
    const values = Object.values(data).filter((v) => v !== undefined)

    if (updates.length === 0) return c.json({ error: 'No fields to update' }, 400)

    const now = new Date().toISOString()
    await c.env.DB.prepare(
      `UPDATE items SET ${updates.join(', ')}, updated_at = ? WHERE id = ?`
    ).bind(...values, now, id).run()

    const updated = await c.env.DB
      .prepare('SELECT * FROM items WHERE id = ?')
      .bind(id).first()

    return c.json(updated)
  }
)

// ── DELETE /api/items/:id ─────────────────────────
itemsRouter.delete('/:id', async (c) => {
  const id = c.req.param('id')
  const { meta } = await c.env.DB
    .prepare('DELETE FROM items WHERE id = ?')
    .bind(id).run()

  if (meta.changes === 0) return c.json({ error: 'Item not found' }, 404)
  return c.body(null, 204)
})

Part 4:D1 マイグレーションとローカル開発

マイグレーションファイル

-- migrations/0001_create_items.sql
CREATE TABLE IF NOT EXISTS items (
  id          TEXT PRIMARY KEY,
  title       TEXT NOT NULL,
  description TEXT,
  status      TEXT NOT NULL DEFAULT 'draft'
              CHECK (status IN ('draft', 'published')),
  created_at  TEXT NOT NULL,
  updated_at  TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_items_status     ON items(status);
CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at DESC);
-- migrations/0002_create_users.sql
CREATE TABLE IF NOT EXISTS users (
  id         TEXT PRIMARY KEY,
  email      TEXT UNIQUE NOT NULL,
  name       TEXT NOT NULL,
  created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);

ローカル開発ワークフロー

# D1 データベースを本番用に作成(初回のみ)
npx wrangler d1 create my-api-production
# → 出力された database_id を wrangler.jsonc に貼り付ける

# ローカルでマイグレーション実行(--local で自動的にローカルSQLiteを使用)
npx wrangler d1 migrations apply my-api-production --local

# シードデータ投入
npx wrangler d1 execute my-api-production --local --command="
  INSERT INTO items (id, title, status, created_at, updated_at)
  VALUES
    ('item-1', 'First Item', 'published', datetime('now'), datetime('now')),
    ('item-2', 'Draft Item', 'draft',     datetime('now'), datetime('now'));
"

# ビルド → ローカルサーバー起動
npm run build
npx wrangler pages dev dist --d1=my-api-production --local --port 3000

# 動作確認
curl http://localhost:3000/api/health
curl http://localhost:3000/api/items
curl -X POST http://localhost:3000/api/items \
  -H "Content-Type: application/json" \
  -d '{"title":"New Item","status":"draft"}'

Part 5:KV・R2 との組み合わせパターン

KV:レートリミット

// src/middleware/ratelimit.ts
import { createMiddleware } from 'hono/factory'
import { type Bindings } from '../types'

export const rateLimitMiddleware = createMiddleware<{
  Bindings: Bindings
}>(async (c, next) => {
  const ip  = c.req.header('CF-Connecting-IP') ?? 'unknown'
  const key = `ratelimit:${ip}`
  const count = parseInt(await c.env.KV.get(key) ?? '0')

  if (count >= 100) {
    return c.json({ error: 'Rate limit exceeded' }, 429)
  }

  // TTL 60秒のスライディングウィンドウ
  await c.env.KV.put(key, String(count + 1), { expirationTtl: 60 })
  return next()
})

KVはセッションキャッシュとしても使える。D1へのクエリを削減する簡単なパターン:

// D1 + KV のキャッシュレイヤー
async function getCachedUser(
  kv: KVNamespace,
  db: D1Database,
  userId: string
) {
  const cached = await kv.get(`user:${userId}`, 'json')
  if (cached) return cached

  const user = await db
    .prepare('SELECT * FROM users WHERE id = ?')
    .bind(userId).first()

  if (user) {
    // 5分間キャッシュ(TTL は用途に応じて調整)
    await kv.put(`user:${userId}`, JSON.stringify(user), { expirationTtl: 300 })
  }
  return user
}

R2:ファイルアップロードエンドポイント

app.post('/api/upload', async (c) => {
  const formData = await c.req.formData()
  const file = formData.get('file') as File | null
  if (!file) return c.json({ error: 'No file provided' }, 400)

  // ファイルサイズ制限(10MB)
  if (file.size > 10 * 1024 * 1024) {
    return c.json({ error: 'File too large (max 10MB)' }, 413)
  }

  const key = `uploads/${crypto.randomUUID()}/${file.name}`
  await c.env.R2.put(key, file.stream(), {
    httpMetadata: { contentType: file.type },
  })

  return c.json({ key, url: `/api/files/${key}` }, 201)
})

app.get('/api/files/:key{.+}', async (c) => {
  const key    = c.req.param('key')
  const object = await c.env.R2.get(key)
  if (!object) return c.json({ error: 'Not Found' }, 404)

  return new Response(object.body, {
    headers: {
      'Content-Type':  object.httpMetadata?.contentType ?? 'application/octet-stream',
      'Cache-Control': 'public, max-age=31536000',
    },
  })
})

Part 6:GitHub Actions による自動デプロイ

手動デプロイは「手順の属人化」と「デプロイ漏れ」を生む。CI/CDに落とし込むことで、git push がそのまま本番反映になる。

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Build
        run: npm run build

      # PRのときはプレビューデプロイ、mainマージは本番デプロイ
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken:   ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId:  ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: my-api
          directory:  dist
          gitHubToken: ${{ secrets.GITHUB_TOKEN }}

この設定により:

  • PR作成 → プレビューURL が自動発行(レビュー可能)
  • mainマージ → 本番に自動デプロイ
  • D1 のマイグレーションは本番デプロイ前に別 job で実行する構成にできる

Part 7:パフォーマンス特性とコスト試算

実測値(参考)

レイテンシ比較(東京ユーザー → API):

  Lambda + API Gateway(ap-northeast-1):
    コールドスタート: 300〜800ms
    ウォーム時:       30〜80ms
    P99:              200〜500ms

  Cloudflare Workers + D1(nrt PoP):
    コールドスタート: 0ms(V8 Isolate は事前準備済み)
    通常時:           10〜30ms(D1クエリ含む)
    P99:              50〜100ms

コスト試算(月100万リクエスト)

構成 内訳 月額
Lambda + API Gateway Lambda $0.2 + API GW $3.5 $3.70
Cloudflare Workers Free Tier 月1,000万リクまで $0
Cloudflare Workers Paid $5/月 + $0.15/100万 $5.15

低〜中トラフィックの API では Workers の無料枠が長期間収まる。


Part 8:Markdown Auto-Discovery ── 記事を増やすだけで自動反映

ここからは、Hono + Cloudflare Pages でブログを構築するときの「もう一つの実装問題」を扱う。

「記事を追加するたびに index ファイルを手動編集する」問題

多くの実装が陥るパターンがある。

// ❌ よくある「管理コスト隠れ負債」パターン
// src/data/blog.ts

export const blogPosts = [
  {
    id: '01',
    slug: 'first-post',
    title: '最初の記事',
    date: '2025-01-01',
    tags: ['TypeScript'],
    excerpt: '...',
    content: `...`,  // ← ここに本文を直書き
  },
  {
    id: '02',
    slug: 'second-post',
    // 記事を追加するたびにここを編集
    // ...
  },
]

このパターンの問題は、記事の追加・編集・削除のたびに blog.ts を手動で同期する必要がある点だ。記事数が増えるほど、この管理コストは線形に膨らむ。ファイルと配列の二重管理になり、どちらが真実の情報源か曖昧になる。

解決策は明快だ。posts/ ディレクトリの Markdown ファイルを唯一の情報源(Single Source of Truth)にする。 インデックスはビルド時に自動生成する。


Part 9:なぜ「ビルド時自動生成」が必要なのか

Cloudflare Workers にはファイルシステムがない

Node.js では fs.readdirSync('posts/') を実行時に呼べる。しかし Cloudflare Workers の実行環境はそれができない。

実行環境の制約:

  Node.js サーバー:
  ├─ fs.readFileSync()  ✅ 実行時に使える
  ├─ fs.readdirSync()   ✅ 実行時に使える
  └─ 動的ファイル読み込み ✅

  Cloudflare Workers (V8 Isolate):
  ├─ fs モジュール      ❌ 存在しない
  ├─ ファイルシステム    ❌ アクセス不可
  └─ 実行時に読めるのはバンドルに含まれたコードのみ

ランタイムでファイルを読めないなら、ビルド時にすべて解決するしかない。

Next.js の getStaticProps がそうであるように、「ビルド時に処理を終わらせてランタイムに渡す」のがエッジ環境での基本戦略だ。

Vite の「バーチャルモジュール」という仕組み

Vite には、実在しないファイルのように見えるモジュールをプラグインが動的に生成する「バーチャルモジュール」という機能がある。

// アプリケーションコードからは「ファイルが存在するかのように」インポートできる
import { allPosts } from 'virtual:posts'

// 実際には vite.config.ts のプラグインがビルド時にこの中身を生成している
// ファイルシステムは実行時には存在しない → Cloudflare Workers で動く

これにより、「ビルド時にファイルを読む」という処理をアプリケーションコードから完全に分離できる。


Part 10:設計の全体像

設計のデータフロー:

  ┌─────────────────────┐
  │ posts/*.md           │  ← 記事追加はここだけ
  │  article-01.md       │
  │  article-02.md       │
  │  article-03.md  ← 新規追加
  └──────────┬──────────┘
             │ npm run build
             ▼
  ┌─────────────────────┐
  │ Vite Plugin          │  ← vite.config.ts に定義
  │  ① .md を列挙        │
  │  ② gray-matter でパース(front matter 抽出)
  │  ③ marked で HTML 変換
  │  ④ JSON 文字列としてエクスポート
  └──────────┬──────────┘
             │ バーチャルモジュールとして注入
             ▼
  ┌─────────────────────┐
  │ virtual:posts        │  ← Worker バンドルに含まれる
  │  allPosts: Post[]    │    (実行時ファイルアクセス不要)
  └──────────┬──────────┘
             │
             ▼
  ┌─────────────────────┐
  │ Hono ルートハンドラ   │  ← import { allPosts } from 'virtual:posts'
  │  /posts             │
  │  /post/:slug        │
  └─────────────────────┘

記事の追加は posts/ にファイルを置くだけ。blog.ts や配列定義、import 文の追加は不要。npm run build がすべてを再生成する。


Part 11:Vite プラグインの実装

フロントマターの設計

Markdown ファイルの先頭に YAML 形式でメタデータを記述する。

---
title: "記事のタイトル"
date: 2026-03-03
readTime: 7
category: Web Dev
tags: Hono, Markdown, TypeScript
description: "OGP・検索結果に表示される説明文"
---

## 本文

本文はここから始まる。

プラグインの実装

// vite.config.ts
import { defineConfig, type Plugin } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { marked } from 'marked'

const VIRTUAL_ID = 'virtual:posts'
const RESOLVED_ID = '\0' + VIRTUAL_ID  // Vite の内部 ID(\0 プレフィックス必須)

// ① ファイルの読み込みとパース
function loadPosts(postsDir: string) {
  if (!fs.existsSync(postsDir)) return []

  const files = fs
    .readdirSync(postsDir)
    .filter((f) => f.endsWith('.md'))
    .sort()

  return files.map((filename) => {
    const raw = fs.readFileSync(path.join(postsDir, filename), 'utf-8')
    const { data, content } = matter(raw)  // front matter と本文を分離

    const slug = filename.replace(/\.md$/, '')

    // タグのノーマライズ:配列と文字列両方に対応
    const rawTags: unknown = data.tags ?? []
    const tags: string[] = Array.isArray(rawTags)
      ? rawTags.map(String)
      : String(rawTags)
          .split(',')
          .map((t) => t.trim())
          .filter(Boolean)

    // ② Markdown → HTML 変換(ビルド時に完結)
    const contentHtml = marked.parse(content) as string

    return {
      slug,
      title:       String(data.title ?? slug),
      date:        String(data.date ?? ''),
      tags,
      description: String(data.description ?? ''),
      contentHtml,
    }
  })
}

// ③ Vite プラグイン本体
function markdownPostsPlugin(): Plugin {
  const postsDir = path.resolve(__dirname, 'posts')

  return {
    name: 'markdown-posts',

    resolveId(id) {
      if (id === VIRTUAL_ID) return RESOLVED_ID
    },

    load(id) {
      if (id !== RESOLVED_ID) return

      // ④ ホットリロード対応
      try {
        if (fs.existsSync(postsDir)) {
          fs.readdirSync(postsDir)
            .filter((f) => f.endsWith('.md'))
            .forEach((f) => this.addWatchFile(path.join(postsDir, f)))
          this.addWatchFile(postsDir)
        }
      } catch {
        // ウォッチ失敗は無視(CI 環境等)
      }

      const posts = loadPosts(postsDir)
      return `export const allPosts = ${JSON.stringify(posts, null, 2)}`
    },
  }
}

型定義(TypeScript アンビエント宣言)

// src/data/posts.ts

declare module 'virtual:posts' {
  export interface Post {
    slug:        string
    title:       string
    date:        string      // ISO 8601 e.g. "2026-08-01"
    tags:        string[]
    description: string
    contentHtml: string      // ビルド時に変換済みの HTML
  }
  export const allPosts: Post[]
}

import { allPosts } from 'virtual:posts'
export type { Post } from 'virtual:posts'
export { allPosts }

/** 全記事を新しい順に並び替えて返す */
export function getSortedPosts() {
  return [...allPosts].sort((a, b) => (a.date < b.date ? 1 : -1))
}

/** スラッグで単一記事を検索 */
export function getPostBySlug(slug: string) {
  return allPosts.find((p) => p.slug === slug)
}

/** "2026-08-01" → "2026年8月1日" */
export function formatDateJa(iso: string): string {
  const d = new Date(iso)
  if (isNaN(d.getTime())) return iso
  return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
}

Hono ルートでの使い方

// src/index.tsx
import { Hono } from 'hono'
import { getSortedPosts, getPostBySlug, formatDateJa } from './data/posts'

const app = new Hono()

// 記事一覧
app.get('/posts', (c) => {
  const posts = getSortedPosts()

  const listHtml = posts
    .map(
      (p) => `
      <article>
        <a href="/post/${p.slug}">
          <h2>${p.title}</h2>
          <time>${formatDateJa(p.date)}</time>
          <p>${p.description}</p>
        </a>
      </article>`
    )
    .join('')

  return c.html(`<!DOCTYPE html><html><body>${listHtml}</body></html>`)
})

// 個別記事
app.get('/post/:slug', (c) => {
  const slug = c.req.param('slug')
  const post = getPostBySlug(slug)

  if (!post) return c.text('Not Found', 404)

  // contentHtml はビルド時に変換済み → ランタイムコストなし
  return c.html(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>${post.title}</title>
      <meta name="description" content="${post.description}">
    </head>
    <body>
      <h1>${post.title}</h1>
      <time>${formatDateJa(post.date)}</time>
      ${post.contentHtml}
    </body>
    </html>
  `)
})

export default app

Part 12:拡張パターンと依存ライブラリの選定

タグ別インデックス

export function getPostsByTag(): Map<string, Post[]> {
  const map = new Map<string, Post[]>()
  for (const post of allPosts) {
    for (const tag of post.tags) {
      const list = map.get(tag) ?? []
      list.push(post)
      map.set(tag, list)
    }
  }
  return map
}

app.get('/tag/:tag', (c) => {
  const tag   = c.req.param('tag')
  const byTag = getPostsByTag()
  const posts = byTag.get(tag) ?? []
  if (posts.length === 0) return c.text('Not Found', 404)
  return c.html(renderPostList(posts, `タグ: ${tag}`))
})

RSS フィードの自動生成

app.get('/rss.xml', (c) => {
  const posts = getSortedPosts().slice(0, 20)
  const items = posts
    .map(
      (p) => `
    <item>
      <title><![CDATA[${p.title}]]></title>
      <link>https://example.com/post/${p.slug}</link>
      <description><![CDATA[${p.description}]]></description>
      <pubDate>${new Date(p.date).toUTCString()}</pubDate>
      <guid>https://example.com/post/${p.slug}</guid>
    </item>`
    )
    .join('')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Blog</title>
    <link>https://example.com</link>
    <description>技術ブログ</description>
    ${items}
  </channel>
</rss>`

  return c.text(xml, 200, { 'Content-Type': 'application/xml; charset=UTF-8' })
})

依存ライブラリの選定根拠

ライブラリ 役割 選定理由
gray-matter YAML front matter のパース デファクトスタンダード。エラーハンドリングが堅牢
marked Markdown → HTML 変換 高速・軽量(バンドルサイズ 27KB)。ビルド時のみ使用
node:fs / node:path ファイル読み込み Vite プラグイン(Node.js環境)でのみ使用。Workerには含まれない

重要なのは node:fsmarkedVite プラグイン内(Node.js環境)でのみ使用され、Cloudflare Workers のバンドルには含まれないことだ。ランタイムバンドルサイズへの影響がゼロ。

操作の比較(Auto-Discovery の効果)

❌ 手動管理(blog.ts に直書き)の場合:
   1. posts/new-article.md を作成
   2. src/data/blog.ts を開く
   3. blogPosts 配列に新しいオブジェクトを追加
   4. title, date, tags, excerpt, content をコピー・転記
   → ファイルを2つ触る。転記ミスのリスクがある。

✅ Auto-Discovery の場合:
   1. posts/new-article.md を作成(front matter を書く)
   → それだけ。npm run build で自動反映。

Conclusion:エッジアーキテクチャの適用判断

Cloudflare Pages + Hono + D1 の構成は「Lambda を全部置き換えるもの」ではなく、特定の条件が揃ったときに最も効率的な選択肢になる。

条件 適合度 理由
グローバルに低遅延が必要 ✅ 最適 300+ PoP で配信
読み取り多・書き込み少のDB ✅ 最適 D1 の強み
サーバー管理ゼロ ✅ 最適 Workers は完全マネージド
高頻度書き込み(秒数百件以上) ❌ 不適 D1 の書き込みスループット制限
長時間バッチ処理 ❌ 不適 CPU時間 50ms 上限
Node.js 固有ライブラリが必要 ❌ 不適 Web API のみ使用可能
複雑なトランザクション △ 要検討 SQLite の制約内で設計が必要

設計の判断基準は「エッジで完結できるか」に尽きる。処理が単一リクエストの応答範囲に収まり、DBアクセスが読み取り中心であれば、このスタックは Lambda + RDS 構成と比較してコスト・レイテンシ・運用工数のすべてで優位に立つ。

Markdown Auto-Discovery も同じ思想だ。「自動化」は複雑な仕組みを作ることではなく、人間が手を動かすべき場所をシステムの端点に押し込める設計の話だ。Vite プラグインという「ビルド時の処理」をうまく使うことで、Cloudflare Workers のランタイム制約を回避しながら、それを実現している。

この記事をシェア

Twitter / X LinkedIn
記事一覧に戻る
🤖
Cloud Assistant
IBM Watson powered

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋