静的HTML生成と配信経路の整理

本サイトでは、Hono JSXを用いてコンポーネントを組み立て、ビルド時に静的HTMLを出力する構成を採用しています。初回レスポンスに必要な本文、メタ情報、hreflang、JSON-LDなどの要素を事前生成できるため、クライアント側での過度なスクリプト実行を待たずにページを描画しやすい利点があります。

一方で、生成したHTMLや付随するアセットをどのような経路で配信するかによって、エッジ基盤の処理負荷や運用の見通しは変わります。配信経路やバックエンドの処理を見直すにあたり、次のような観点を整理しました。

  • 静的アセットの配信時にWorkerの実行を適切に抑制できているか
  • 複数サイトのルーティングを単一Worker内の条件分岐に過度に集約していないか
  • 更新頻度の異なるHTMLと不変アセットでキャッシュ方針を切り分けられているか
  • 外部通知を伴うAPIでHTTPレスポンスの待機時間が増加していないか
  • 関連する複数のD1クエリ実行に伴うネットワーク往復を抑えられているか

これらの課題に対し、各サイトの配信要件を整理し、静的ファイルの配信はCloudflare Assetsを活用しつつ、動的APIや形式切り替え制御をWorkerで担う構成への見直しを行いました。

アーキテクチャの概要

src/ + content/
  -> build-site.ts
  -> public/
  -> prepare-site-deployments.ts + prepare-agent-assets.ts
  -> dist/sites/<site>/
  -> site-specific Worker + Assets

HTML -> Worker -> HTML / Markdown
/assets/* -> Assets
main /assets/ffmpeg-core/* -> Worker -> R2
POST /api/contact -> validation -> Rate Limiting -> Turnstile -> D1 -> Queue -> Email

開発リポジトリと生成処理を一元管理しながら、配信環境や設定の境界をサイト単位で整理するアプローチを採っています。

1. 静的アセット配信とWorker実行の境界設計

メインサイト(muscleindustry.work)のWrangler設定では、Workerを静的アセットより優先して実行するパスを明示的に指定しています。

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

設定されたパスのうち、ルートパス(/)、各言語ページ(/ja、/ja/*、/en、/en/*)、MCPエンドポイント(/mcp)、問い合わせなどのAPI群(/api/*)、ヘルスチェック(/healthz)、およびR2から配信するFFmpeg WASMバイナリ(/assets/ffmpeg-core/*)は run_worker_first によりWorkerが先行して実行されます。

通常ページのHTMLリクエストも、ブラウザ表示とMarkdown形式(Accept: text/markdown)の動的な切り替えに対応するためWorkerを経由します。

一方、/assets/* 配下に配置される画像、CSS、JavaScriptなどの一般的な静的アセットは、Workerを介さずCloudflare Assetsから直接配信されます(mainサイトの /assets/ffmpeg-core/* はWorker経由でR2から配信する例外です)。

静的アセットへのアクセスで不要なWorker処理を挟まない構成にすることで、エッジ基盤のリソース消費や実行オーバーヘッドを抑えやすくしています。

2. サイト別WorkerとAssetsによるホスト分割

全14の公式関連サイトは、単一のWorkerでホスト名を判定して振り分けるのではなく、サイトごとにWorkerとAssetsを組み合わせた独立した構成として分離しています。

dist/sites/main            -> muscle-industry-corporate
dist/sites/nihongo-pocket  -> muscle-industry-nihongo-pocket
dist/sites/kage            -> muscle-industry-kage
dist/sites/madobeya        -> muscle-industry-madobeya
dist/sites/sokudoku        -> muscle-industry-sokudoku
...                       -> one Worker per official site

ビルド処理後に、各ホストに必要なHTMLやアセットを選別してそれぞれの配置ディレクトリへ格納します。各サイトの設定ファイルで自ドメインの配信ディレクトリやルーティングを個別に管理します。

ホスト単位で設定と配信境界を分けることで、他サイト向けコンテンツの混入や設定の波及リスクを低減しやすくなります。また、サイトの用途に応じたコンテンツセキュリティポリシー(CSP)の適用や、不要なバインディングの排除が進めやすくなります。

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 ハッシュが付与されないアセットは、Etag等による再検証を前提とする。
XML / TXT(sitemap, robots等) public, max-age=3600 クローラー向け情報は短期間のキャッシュで適度に鮮度を保持。
動的API(/api/*) no-store トランザクション処理や個別応答のためキャッシュを無効化。

max-age=0 は保存自体の禁止ではなく、ブラウザに対して利用前の再検証を求める指定です。エッジ側のキャッシュ設定と組み合わせることで、更新反映の確認と配信効率の両立を図っています。なお、stale-while-revalidate はバックグラウンド更新中の応答を許容する仕組みであり、オリジン障害時の完全な可用性を担保するものではありません。

ファイル名に内容ハッシュが含まれるアセットは、更新時にURL自体が切り替わるため immutable を指定して効率的な再利用を促します。

_headers におけるルールの結合への配慮

Cloudflareの _headers 設定では、パスのワイルドカード指定が重なると Cache-Control ヘッダーが結合される場合があります。

意図しないヘッダー結合を避けるため、個別ルールの冒頭で ! Cache-Control を指定して親設定をクリアした上で、目的の値を明示的に宣言する記法を用いています。

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

/assets/app-ABC12345.js
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable

ビルドスクリプト側で生成アセットのハッシュ付きファイル名を検出し、該当ファイルに対する完全一致ルールを出力することで設定の整合性を保っています。

4. 問い合わせ処理の多層防御と非同期連携

問い合わせフォームAPI(POST /api/contact)では、外部メールサーバーへの送信完了をHTTPリクエスト内で同期待ちせず、Worker側で検証とデータ永続化を行った上で非同期キューへ処理を渡しています。

Worker内での処理フローは以下の通りです。

  1. ペイロードと入力値の検証: 送信されたJSONのサイズ制限、およびZodスキーマによる型・形式の検証
  2. レート制限: Cloudflare Rate Limiting bindingによる短時間の集中アクセスの制御
  3. ボット対策: Cloudflare Turnstileによるトークン、action、hostnameの検証
  4. データベース保存: Cloudflare D1へ問い合わせレコードを永続化
  5. キュー投入: メール送信用ジョブをCloudflare Queuesへ投入
  6. 応答返却: 状態に応じたHTTPステータスをクライアントへ返却

状態管理とエラーハンドリング

D1への保存処理が失敗した場合はHTTP 500等のエラーを返し、受付完了とはみなしません。D1への保存が成功し、Queueへの投入も完了した段階で 202 Accepted(受付完了)を返却します。

もしD1への保存後にQueueへの投入処理で一時的なエラーが発生した場合でも、データ自体はDBに保存されているため、レスポンスの状態値として accepted_email_retry を返してクライアントへは 202 Accepted を応答しつつ、D1側で再試行待ち(retry_pending 等)としてステータスを管理し、後続の定期処理による再通知の対象として残します。なお、DBへの保存成功やHTTP 202の返却は、メールの宛先への到達そのものを保証するものではありません。

キュー処理側では、Cloudflare Email binding経由でのメール送信を試行します。一時的な外部要因で送信できない場合は、設定された上限(最大5回)まで再試行され、解消しない場合はDLQ(Dead Letter Queue)へ退避されます。また、キューの「少なくとも1回配信」という特性を考慮し、問い合わせIDに基づくステータス管理で多重送信の抑制を図っています。

5. レート制限とデータ永続化の役割分担

API保護におけるアクセス制御は、役割に応じてレイヤーを分けています。

短時間の急激なリクエスト集中に対しては、Worker内で迅速に判定できる「Cloudflare Rate Limiting binding」を活用します。一方で、ユーザー単位の時間枠制限や日単位のクォータ管理など、状態の追跡が必要な判定にはCloudflare D1を利用します。

用途に合わせて判定層を分けることで、短時間の集中アクセスによる過度なDB負荷を抑えつつ、必要な利用枠管理を行っています。

6. 複数クエリの集約と整合性確保

関連する複数レコードの挿入や更新を個別クエリで順次実行すると、ネットワーク往復が増加し、途中で失敗した際のデータ不整合の原因にもなります。

本システムでは、関連する一連のSQL操作を配列としてまとめ、D1.batch() を用いて1回のAPI呼び出しで実行しています。

// Illustrative tables and identifiers, not an application migration.
const statements = [
  db.prepare("INSERT INTO example_items (id, value) VALUES (?, ?)").bind(itemId, value),
  db.prepare("UPDATE example_jobs SET status = ? WHERE id = ?").bind("ready", jobId),
];
await db.batch(statements);

D1.batch() は渡されたSQL文を順に実行し、トランザクションとして扱われるため、途中で失敗した場合はロールバックされます。これによりDB内のデータ整合性を保ちやすくしています(外部のキュー投入やメール送信までを同一トランザクションとして原子的に確定するものではありません)。

7. 設定管理とローカル検証を軸とした公開フロー

インフラ定義とコードの整合性を保つため、Wrangler設定ファイルを通じて環境変数のバインディングや型定義の生成を行っています(一部のDNSや周辺インフラ設定にはTerraformなども併用されています)。

ログ出力においては、利用者のプライバシー保護のため、IPアドレス、問い合わせ本文、各種認証トークンなどの機密情報をログへ送信しない方針を設けています。デプロイ時の --strict 指定は意図しない設定変更の検知を助けますが、あらゆる設定差異を完全に防ぐものではないため、明示的な確認手順を併用しています。

また、通常のリポジトリへのpushやPR作成だけで自動的にCI/CDが起動して本番反映される構成にはしていません。公開にあたっては以下の検証フローを段階的に踏んでいます。

  • コードや原稿の変更時:
    • ローカル環境での quality:ci(audit、format、typecheck、unit、build、Workers tests)の実行
    • security:osv による脆弱性スキャン
    • git diff --check による差分や余分な空白の検査
  • 表示やSEOに関わる変更時:
    • 対象ページのローカルブラウザでの表示・動作確認
    • Lighthouseによる検証

同一コミットSHAでローカル検証を通過させた上で、明示的にリリースを実行し、本番環境で動作確認を行う運用を採っています。

検証における指標と合否基準

アーキテクチャの変更にあたっては、以下の点を確認しながら進めています。

  • 静的アセットがWorkerを介さずAssetsから配信されているか(mainサイトの /assets/ffmpeg-core/* がWorker経由でR2から配信される例外を含む)
  • レスポンスヘッダーのCache-Controlが意図通り設定されているか
  • ホストごとの配信境界が保たれ、別サイトのリソースが混入していないか
  • 動的APIのキャッシュが無効化され、非同期ジョブや再試行機構が想定通り動作するか

また、品質検証の合否基準として、LighthouseのPerformance 95以上、SEO 100、CLS 0.1未満といった目標値を設けて退行を監視しています。

Lighthouseのスコアは開発環境での診断指標であり、実ユーザーのアクセス環境を反映するCore Web Vitals(LCP、INP、CLS)や実際の検索順位とは性質が異なるため、それぞれを混同せず別個の観点として捉えています。

静的配信で対応できる範囲を明確にし、Workerの役割を制御レイヤーとして整理することで、配信効率と運用の見通しを両立させています。

参考技術資料