公開: 2025年12月29日 11:00:00 JST

elm-pagesでSSR: Cloudflare Pages対応

Note: この記事は、実装作業を行ったGitHub Copilotのコーディングエージェント自身が執筆したものです。 人間の指示をもとに実装計画書を作成し、その計画に従って実装作業を進めました。 この記事は、実装計画書や作業ログを元に、技術的な詳細を包括的にまとめています。

このサイト(ymtszw.cc)はelm-pagesを使って作られています。

elm-pages v3では、静的サイト生成(Static Site Generation, SSG)だけでなく、server-side rendering(SSR)機能も提供されています。 この記事では、Cloudflare Pages Functions上でSSRを動作させるためのadapterを実装した過程を記録します。

背景と動機

このサイトをCloudflare Pagesにデプロイしていますが、elm-pagesの公式Cloudflare Pages adapterが存在しなかったため、自分で実装しました。

elm-pagesには公式のNetlify adapterがリファレンス実装として存在します。 コミュニティではExpressFastifyAWS Lambdaなどのプラットフォーム向けadapterが開発されていますが(Discussion #378参照)、Cloudflare Pages Functions向けの実装は存在しませんでした。

Cloudflare Pages Functions

Cloudflare Pages Functionsは、Cloudflare Workersベースのサーバーサイド実行環境です。

adapter実装で重要な特徴:

詳細はCloudflare Pages Functions公式ドキュメントを参照してください。

アーキテクチャ設計

全体の流れ

elm-pages build
  ↓
elm-pages.config.mjs
  ↓ adapter/cloudflare-pages.js を実行
  ↓
├─ dist/ (静的アセット)
│  ├─ _routes.json (ルーティング設定)
│  └─ ... (HTML, CSS, JS等)
└─ functions/ (Server-side)
   ├─ [[path]].ts (catch-allハンドラ)
   └─ elm-pages-cli.mjs (renderエンジン)

主要コンポーネント

1. Adapter関数(adapter/cloudflare-pages.js)

elm-pages.config.mjsから読み込まれ、elm-pagesのビルド時に実行される関数で、以下を行います:

export default async function run({
  renderFunctionFilePath,
  routePatterns,
  apiRoutePatterns,
}) {
  // 1. renderエンジンをコピー
  fs.copyFileSync(renderFunctionFilePath, "./functions/elm-pages-cli.mjs");

  // 2. ハンドラを生成
  fs.writeFileSync("./functions/[[path]].ts", handlerCode());

  // 3. ルーティング設定を生成
  const routesJson = generateRoutesJson(routePatterns, apiRoutePatterns);
  fs.writeFileSync("./dist/_routes.json", JSON.stringify(routesJson, null, 2));
}

2. Functions Handler(functions/[[path]].ts)

Cloudflare Pages FunctionsのonRequestハンドラを実装します:

export async function onRequest(context) {
  // 1. Fetch API Request → elm-pages形式に変換
  const elmPagesRequest = await reqToJson(context.request);

  // 2. elm-pages renderエンジンを実行
  const renderResult = await elmPages.render(elmPagesRequest);

  // 3. 結果 → Fetch API Responseに変換
  return new Response(renderResult.body, {
    status: renderResult.statusCode,
    headers: renderResult.headers,
  });
}

3. _routes.json

どのパスをFunctions経由にするか、どのパスを静的配信にするかを制御します:

{
  "version": 1,
  "include": ["/server-test"],
  "exclude": [
    "/assets/*",
    "/*.html",
    "/*.js",
    "/*.css"
  ]
}

リクエスト処理のデータフロー

実際のリクエスト処理がどのように流れるかを、以下の図で示します。

sequenceDiagram
    participant Client as クライアント<br/>(ブラウザ)
    participant CF as Cloudflare Pages
    participant Routes as _routes.json
    participant Handler as functions/[[path]].ts
    participant Render as elm-pages-cli.mjs
    participant Elm as Elmアプリケーション

    Client->>CF: HTTPリクエスト<br/>(例: GET /server-test)

    CF->>Routes: ルーティング判定

    alt 静的ファイル (exclude)
        Routes-->>CF: 静的配信
        CF-->>Client: 静的ファイル返却
    else SSRパス (include)
        Routes->>Handler: Functions実行

        Note over Handler: Request変換処理
        Handler->>Handler: reqToJson()
        Note right of Handler: Fetch API Request<br/>↓<br/>elm-pages形式<br/>{ method, rawUrl,<br/>  headers, body }

        Handler->>Render: render(elmPagesRequest)

        Note over Render: elm-pagesエンジン
        Render->>Elm: BackendTask実行<br/>Data取得
        Elm-->>Render: Data
        Render->>Elm: view関数実行<br/>HTML生成
        Elm-->>Render: HTML

        Render-->>Handler: renderResult<br/>{ statusCode, body,<br/>  headers, kind }

        Note over Handler: Response変換処理
        Handler->>Handler: new Response()
        Note right of Handler: elm-pages形式<br/>↓<br/>Fetch API Response (HTML / JSON / bytes)

        Handler-->>CF: Response
        CF-->>Client: レスポンス(HTML または API レスポンス)
    end

データフローの詳細:

  1. リクエスト受信: クライアントからCloudflare Pagesにリクエストが届く
  2. ルーティング判定: _routes.jsonに基づいて処理方法を決定
    • excludeパターンにマッチ → 静的ファイルとして配信
    • includeパターンにマッチ → Functions経由でSSR
  3. Request変換: reqToJson()でFetch API RequestをElm-pages形式に変換
    {
      method: "GET",
      rawUrl: "/server-test",
      headers: [["user-agent", "..."], ...],
      body: null
    }
    
  4. elm-pagesレンダリング: renderエンジンがElmアプリケーションを実行
    • BackendTaskでデータ取得
    • (HTML routeの場合)view関数でHTML生成
  5. Response変換: elm-pages形式の結果をFetch API Responseに変換します。
  6. レスポンス返却: クライアントにレスポンスを返します(HTML、JSON API、バイナリなど)。

実装の詳細

Phase 1: 基本的なadapter実装

最初に、Netlify adapterを参考にしながら、基本的な構造を実装しました。

実装したファイル:

ポイント:

Phase 2: Server-render routeのテスト

実際にSSRが動作するかテストするため、/server-testページ(実装)を作成しました。以下の情報を表示します:

補足: 後にAPI routeも実装しました。FunctionsハンドラでHTMLレスポンスとJSON APIレスポンスを切り替えて処理できるようにし、APIエンドポイントからのJSON応答を検証できるようにしています。

Phase 3: ローカル開発環境の整備

wrangler.tomlの作成

compatibility_date = "2025-12-11"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "dist"

ローカル開発サーバーの起動

ビルド後、wranglerでローカルにCloudflare Pages環境をシミュレートできます:

npx wrangler pages dev dist

これにより、http://localhost:8788でadapter経由のSSR動作を確認できます。

Runtime detection機能

開発中、elm-pages devサーバーとwranglerのどちらで動いているか判別する必要がありました。 そこで、カスタムヘッダーを注入する仕組みを追加:

// adapter内でヘッダーを注入
headers["x-elm-pages-cloudflare"] = "true";
// レスポンスヘッダーにも追加
responseHeaders.set("x-elm-pages-cloudflare", "true");

Elmコード側で検出(app/Route/ServerTest.elm):

view :
    App Data ActionData RouteParams
    -> Shared.Model
    -> View (PagesMsg Msg)
view app _ =
    let
        cloudflareHeader =
            app.data.headers
                |> List.filter (\( key, _ ) -> String.toLower key == "x-elm-pages-cloudflare")
                |> List.head

        isCloudflare =
            cloudflareHeader /= Nothing

        runtimeInfo =
            if isCloudflare then
                "✅ Running on Cloudflare Pages Functions (or wrangler dev)"
            else
                "⚠️ Running on elm-pages dev server (adapter not active)"
    in
    -- ... view body

開発時の技術的課題と解決策

1. globby v14のimport問題

問題: wranglerでバンドル時にunicorn-magicパッケージのimportエラーが発生(sindresorhus/globby#260

解決: globby v16にアップグレード

"dependencies": {
  "globby": "^16.0"
}
2. Node.js互換モジュールの警告

問題: path, fsなどのNode.js組み込みモジュール使用時の警告

解決: wrangler.tomlnodejs_compatフラグを有効化

compatibility_flags = ["nodejs_compat"]
3. MODULE_TYPELESS_PACKAGE_JSON警告

問題: wranglerでのバンドル時に、.jsファイルがES Modules(import/export構文)として認識されず警告が発生

意味: "type": "module"を指定すると、Node.jsが.jsファイルをES Modules形式として扱う。指定しない場合はCommonJS(require/module.exports)がデフォルト

このプロジェクトで採用可能な理由:

解決: package.json"type": "module"を追加

{
  "type": "module"
}
4. 静的アセットの除外

問題: _routes.jsonで静的アセットを除外しないと、静的ファイルへのリクエストにもFunctionsが実行されてしまい、不要なコストとレイテンシが発生します。動的なファイルスキャン(fs.readdir)はCloudflare Workers環境で使えないため、実行時に判定できません

解決: adapter内で静的アセットパターンを事前定義し、_routes.jsonexcludeに追加

const staticAssetPatterns = [
  "/assets/*",
  "/*.html",
  "/*.js",
  "/*.css",
  "/*.json",
  // ... 17パターン
];

Phase 3.5: 実環境デプロイとCI/CD統合

GitHub Actionsワークフロー

Pull Request時の自動プレビューデプロイと、masterブランチマージ時の本番デプロイを実現。

主要な実装:

  1. PRプレビューデプロイ: cloudflare/wrangler-action@v3を使用
  2. プレビューURLの自動コメント: Branch URLとCommit URLの両方を投稿
  3. 本番デプロイ: masterマージ時に--branch=mainで本番環境へデプロイ
- name: Deploy to Cloudflare Pages (Preview)
  if: github.event_name == 'pull_request'
  id: deploy-preview
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: pages deploy dist --project-name=ymtszw-github-io --branch=${{ github.head_ref }}

- name: Comment preview URL on PR
  if: github.event_name == 'pull_request'
  uses: actions/github-script@v7
  with:
    script: |
      const branchUrl = "${{ steps.deploy-preview.outputs.pages-deployment-alias-url }}";
      const commitUrl = "${{ steps.deploy-preview.outputs.deployment-url }}";
      await github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `🚀 Preview deployment ready!\n\n**Branch URL:** ${branchUrl}\n**Commit URL:** ${commitUrl}`
      });

必要な権限:

permissions:
  contents: read
  pull-requests: write  # PRコメント投稿に必要

実環境での動作確認

プレビュー環境で確認した項目:

Phase 4: E2E自動テスト

CI環境でadapterの動作を自動検証するため、実デプロイ環境でのsmoke testを実装。

テストの仕組み

プレビューデプロイ完了後、以下をチェック:

  1. 静的ページのHTTP 200レスポンス
  2. SSRページのHTTP 200レスポンスと内容確認
  3. Runtime detectionヘッダー(x-elm-pages-cloudflare: true)の存在
  4. 静的アセット(robots.txt)の配信

Smoke testスクリプト

実装(tests/e2e/wrangler-smoke.sh)では、デプロイ完了後の実環境URLに対してテストを実行:

#!/usr/bin/env bash
DEPLOY_URL="$1"

# Test 1: 静的ページ
HTTP=$(curl -s -o /dev/null -w '%{http_code}' "$DEPLOY_URL/" || true)
if [ "$HTTP" != "200" ]; then
  echo "✗ Index page returned $HTTP"
  exit 1
fi

# Test 2: SSRルート
HTTP=$(curl -s -o /dev/null -w '%{http_code}' "$DEPLOY_URL/server-test" || true)
curl -s "$DEPLOY_URL/server-test" | grep -q "Running on Cloudflare Pages"

# Test 3: Runtime detectionヘッダー
curl -s -I "$DEPLOY_URL/server-test" | grep -i 'x-elm-pages-cloudflare: true'

# Test 4: 静的アセット
HTTP=$(curl -s -o /dev/null -w '%{http_code}' "$DEPLOY_URL/robots.txt" || true)

実行:

デプロイ後にワークフローから呼び出し:

- name: Run smoke test on preview
  run: bash tests/e2e/wrangler-smoke.sh ${{ steps.deploy-preview.outputs.pages-deployment-alias-url }}

これにより、各PR/コミットで自動的にSSR機能とruntime detectionが検証されます。

使用方法

ローカル開発

elm-pages devサーバー(adapter非経由)

npx elm-pages dev --debug

wranglerでの動作確認(adapter経由)

# ビルド
npx elm-pages build

# wranglerでローカル起動
npx wrangler pages dev dist

http://localhost:8788でCloudflare Pages環境がローカルで動作します。

デプロイ

自動デプロイ(GitHub Actions)

  1. PRプレビュー: Pull Request作成時に自動デプロイ

    • プレビューURLがPRにコメントされる
    • ブランチURL: https://<branch-name>.<project-name>.pages.dev
    • コミットURL: https://<commit-hash>.<project-name>.pages.dev
  2. 本番デプロイ: masterブランチへのマージで本番環境に自動デプロイ

手動デプロイ(wrangler CLI)

# プレビュー環境(ブランチ名の指定が必要)
npx wrangler pages deploy dist --project-name=<your-project> --branch=<your-branch-name>

# 本番環境
npx wrangler pages deploy dist --project-name=<your-project> --branch=main

まとめ

elm-pages v3のCloudflare Pages Functions adapterを実装することで、 静的サイト生成とserver-side renderingを組み合わせた柔軟なサイト構築が可能になりました。

実装の成果:

開発体験の向上:

この実装は、将来的にはelm-pagesのコミュニティに還元し、 他の開発者も簡単にCloudflare Pagesでelm-pagesを使えるようにしたいと考えています。


編集後記

この欄は人間(ymtszw)が記述している

雑感

記事を最初にドラフトさせたときは、実装計画書を抜粋したようなものが出てきた。それだと冗長だったり逆に書いてほしいところを書いてなかったりしたので、記事として外部公開するに値しそうな内容を逐一指示した。この辺は人間の感覚が重要そうである。例えば、公式docを見れば書いてありそうなことや、想定読者がすでに知ってそうなことは最小限にして、今回の実装内容の独自性に関わる部分に集中させたい。

途中、mermaidシーケンス図でデータフローを説明させたところがあるが、これはなかなかわかりやすい図がスパっと出てきて感心した。感心したので、思わず実際にmermaidを描画する機能を搭載することにした。そっちも当然GitHub Copilotにやらせた

その途中で発見したこととして、mermaid.jsをbundleした場合、なぜかelm-pages buildが失敗するという現象があり、解決できなかった。何らかビルド中の非同期処理の待ち合わせが正しく実装されていないと見える。この点の深堀りは避けてmermaid.jsはCDNから読み込む形にした。

補足: Node.js互換性

今回実装されたCloudflare Pages Adapterのcatch-all handler関数それ自体は特にNode.jsモジュールを使用していないが、そこから呼び出されるelm-pagesのrenderエンジンがNode.js組み込みモジュール(path, fs等)を結構使用している、という関係性にあり、結果としてnodejs_compatフラグが必要だった。

公開: 2025年12月29日 11:00:00 JST