「Honoで静的生成」は、まだ入口だった

このサイトは以前から、Hono JSXでページを組み立て、ビルド時にHTMLへ変換する構成でした。ブラウザへ巨大なJavaScriptを送り、実行後に画面を作るSPAではありません。最初のレスポンスに本文、meta、hreflang、JSON-LDまで入っているため、この時点でも十分に高速です。

しかし、静的HTMLを作っただけでは配信経路まで最短になったとは限りません。

  • 静的ファイルも毎回Hono Workerを通っていないか
  • 複数ドメインを一つのWorkerで振り分けていないか
  • HTMLとハッシュ付きCSSへ同じキャッシュ設定を当てていないか
  • メール送信の完了をAPIレスポンスが待っていないか
  • 複数のD1書き込みを逐次実行していないか

今回はこの境界を見直し、Honoを「すべてを通す入口」から「動的処理だけを担当する薄い制御面」へ変えました。

最終構成

build-site.ts
  -> public(全ページとアセットを静的生成)
  -> prepare-site-deployments.ts
       -> dist/sites/main            -> Assets + Hono Worker
       -> dist/sites/nihongo-pocket  -> Assets only
       -> dist/sites/kage            -> Assets only
       -> dist/sites/madobeya        -> Assets only
       -> dist/sites/sokudoku        -> Assets only

main Worker
  /, /api/*, /healthz, /assets/ffmpeg-core/* -> Honoを先に実行
  その他のHTML/CSS/JS/画像                    -> Assetsが直接応答

POST /api/contact
  -> Rate Limiting -> Turnstile -> D1保存 -> Queue -> Email
                                                -> 5回失敗 -> DLQ

POST /api/transcript-jobs
  -> Rate Limiting -> D1時間上限 -> D1.batch -> Queue
                                             -> 5回失敗 -> DLQ

重要なのは、静的生成と動的APIを同じプロジェクトで管理しながら、実行経路は分けていることです。

1. 通常ページではWorkerを起動しない

メインサイトのAssets設定では、Honoを先に動かすURLを配列で限定しています。

{
  "assets": {
    "directory": "./dist/sites/main",
    "binding": "ASSETS",
    "html_handling": "drop-trailing-slash",
    "run_worker_first": [
      "/",
      "/api/*",
      "/healthz",
      "/assets/ffmpeg-core/*"
    ]
  }
}

/ja/en、ブログ、CSS、画像などはAssetsが先に見つけ、そのまま返します。Honoが必要なのは、言語判定を行う /、API、ヘルスチェック、R2から取得する特殊なランタイムだけです。

Hono自体は軽量ですが、処理が不要なリクエストでisolateを起動しないほうが、実行経路、CPU時間、障害面のすべてを小さくできます。

2. サブドメインをAssets-only Workerへ分割する

以前は、一つのWorkerがhostnameを見て複数サイトを振り分ける余地がありました。現在は各プロダクトサイトを独立したAssets deploymentとして配信しています。

muscleindustry.work                 -> main Worker + Assets
nihongo-pocket.muscleindustry.work -> Assets only
kage.muscleindustry.work           -> Assets only
madobeya.muscleindustry.work       -> Assets only
sokudoku.muscleindustry.work       -> Assets only

ビルド元は一つですが、デプロイ前にホストごとのディレクトリへ必要なHTMLとアセットだけをコピーします。各 wrangler.<site>.jsonc はCustom Domainと自分の配布ディレクトリだけを持ちます。

この分割には速度以外の効果もあります。

  • 別ホスト用HTMLを誤って公開しない
  • hostname分岐とrewriteを実行時から消せる
  • 一つの設定ミスが全サイトへ波及しにくい
  • サイトごとにCSPを最小化できる
  • 静的サイト側にはD1、R2、秘密値をbindingしなくてよい

静的サイトが増えるほど「一つの万能Worker」より「生成は共通、配信境界は独立」のほうが管理しやすくなります。

3. キャッシュはファイルの性質で分ける

このサイトでは次のポリシーを使っています。

対象 Cache-Control
HTML public, max-age=0, s-maxage=86400, stale-while-revalidate=604800
ハッシュ付きCSS/JS public, max-age=31536000, immutable
その他の /assets/* public, max-age=0, must-revalidate
robots、sitemap、RSS、app-ads public, max-age=3600
API no-store

HTMLはブラウザに固定せず、Cloudflareのエッジでは24時間再利用します。更新確認中も最長7日間は古い正常レスポンスを返せるため、オリジン処理を増やさず可用性を保てます。

一方、ファイル名に内容ハッシュが入ったCSSとJavaScriptは1年間immutableです。内容が変わればURLも変わるため、再検証は不要です。画像などハッシュを持たないアセットには同じ設定を使いません。

_headers の継承に注意する

移行時に実際に見つかった落とし穴が、Cloudflareの _headers は複数の一致ルールをマージすることです。

/*/assets/*/assets/site-HASH.css がすべて一致すると、同名の Cache-Control がカンマ区切りで結合されることがあります。具体的なルールで親の値を外してから設定し直します。

/*
  Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=604800

/assets/*
  ! Cache-Control
  Cache-Control: public, max-age=0, must-revalidate

/assets/site-7482c240a6.css
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable

ハッシュ付きファイルはビルドごとに変わるため、prepare-site-deployments.ts が生成物を走査し、完全一致ルールを自動生成します。配信後は curl -D - でも実際のレスポンスヘッダーを確認します。

4. 問い合わせ受付とメール送信を分離する

問い合わせAPIで遅くなりやすいのは、外部メール処理をリクエスト内で待つことです。現在は次の順番にしました。

  1. JSONサイズとZod schemaを検証
  2. Cloudflare Rate Limiting bindingで短時間のburstを制限
  3. Turnstileのactionとhostnameを検証
  4. D1へ問い合わせを保存
  5. Queueへ通知ジョブを投入
  6. APIは 202 Accepted を返す
  7. Queue consumerがEmail bindingで送信

D1への保存完了を受付の境界にするため、メール側が一時的に遅くても入力内容は失いません。Queueはat-least-once deliveryなので、問い合わせIDからMessage-IDを固定し、D1の email_statussent なら再送しない設計にしています。

consumerは最大5回再試行し、それでも失敗したメッセージは専用DLQへ移します。Queue投入自体に失敗してもD1へ retry_pending として残り、日次Cronが回収します。

これは単なる速度改善ではなく、ユーザーへ早く応答しながら配送の信頼性を上げる変更です。

5. レート制限は短時間と永続上限を分ける

Rate Limiting bindingは、問い合わせやAI APIへのburstをWorkerの手前に近い場所で止める用途に向いています。ただし、場所や時間をまたぐ厳密な利用枠まで一つの短期カウンターへ任せません。

動画文字起こしでは二段階にしています。

  • Rate Limiting binding:60秒単位の連打を制限
  • D1:ユーザーごとの1時間単位ジョブ上限を永続的に確認

以前のKVベースのカウンターはWorker bindingから外しました。低コストな短期制御は専用binding、監査可能な利用枠はD1という役割分担です。

6. 関連するD1書き込みは batch() へまとめる

動画、アセット、文字起こしジョブを別々にINSERTすると、途中で失敗したときに孤児レコードが残ります。現在は関連するstatementを作り、D1.batch() でまとめて実行します。

await db.batch([
  insertVideoStatement,
  insertAssetStatement,
  insertTranscriptJobStatement,
]);

ネットワーク往復を減らし、関連レコードの整合性も保ちやすくなります。大量のsegment更新も同じ考え方でまとめています。

7. 型、workerdテスト、観測を構成の一部にする

高速な構成でも、Dashboardとコードのbindingがずれれば本番で壊れます。設定のsource of truthを wrangler.jsonc に寄せ、wrangler typesCloudflareBindings を生成します。

テストは二層です。

  • Node上のVitest:コンテンツ、レンダリング、API契約、Queue consumer
  • Cloudflare Workers Vitest integration:workerd上のfetch、Assets、D1

本番Workerでは構造化ログ、全リクエストのログ、5%のtrace、X-Request-ID を有効化しました。ただし、IP、生の問い合わせ本文、tokenはログへ出しません。

デプロイは wrangler deploy --strict を使い、意図しないDashboard driftがあれば止めます。CIは品質ゲート、D1 migration、R2ランタイム、Queue/DLQ、静的4サイト、メインWorker、5ドメインのsmoke testという順番です。

計測結果

移行前後で見たのはLighthouseの点数だけではありません。Workerを通るURL、レスポンスヘッダー、ホスト分離、APIのキャッシュ禁止、QueueとD1の実接続まで確認しました。

24回のLighthouse計測では、最小Performance 97、SEO 100、最大LCP 2.33秒、TBT 0ms、CLS 0を確認しました。速度の数値を維持しながら、通常ページのWorker実行範囲と障害範囲を小さくできたことが今回の主要な成果です。

この構成が向くサイト

この構成は、すべてのWebアプリに対する万能解ではありません。リクエストごとに認証済みHTMLを変えるアプリや、リアルタイム処理が中心のサービスでは別の設計が必要です。

相性がよいのは次のようなサイトです。

  • 大部分が公開コンテンツで、ビルド時に生成できる
  • SEOと初期HTMLが重要
  • 問い合わせや一部のAI機能だけが動的
  • 複数のLPやプロダクトサイトを同じリポジトリで管理する
  • Cloudflare上で配信、API、D1、R2、Queueを完結させたい

静的にできるものはビルド時に確定し、Assetsへ直接返させる。Honoは動的な境界だけを明確に担当する。今回の「さらに爆速化」は、細かな最適化を積むより、不要な実行経路そのものを消す作業でした。

参考資料