CloudFront Functions で Next.js のパス解決の真似をする

Next.js から static export した HTML を S3 に配置して CloudFront から配信する, というのはそこそこよくある構成なんじゃないかと思います. 知らんけど.

ところで素朴に CloudFront の origin に S3 を指定するだけでは, ページのパスの解決がうまくいきません. 例えば /pages/about.tsx というページコンポーネントがあった場合, 通常の Next.js であれば /about というパスでこのページにアクセスできますが, 素朴な構成の場合は static export されたファイル名と完全一致する /about.html というパスでないとページにアクセスできません1.

この問題を解決するための方法としては以下のようなものが知られています.

どれを使ってもそれなりに動くようにはなりますが, パスの解決だけをしたいという用途においては CloudFront Functions が一番マシな選択肢なんじゃないでしょうか. パス解決の方法はできるだけ Next.js のものを真似をできると良いはずですが, S3 の static website hosting にはそこまでの柔軟性がありませんし, 一方で Lambda@Edge は柔軟性は高いですが目的に対してはやや大袈裟な印象です.

Next.js のパス解決を真似た CloudFront Function

ということで CloudFront Function を書いていくわけですが, 上述のとおりパス解決の方法はできるだけ Next.js のものを真似できると良いはずです. 手元では本物の Next.js を使って開発をしているわけなので, 本番環境での動作もそれと同じである方が不安が少ないです.

ということで出来上がったものがこちら (参照している Next.js のバージョンは 13).

あらかじめ予防線を貼っておくと, 真面目に Next.js のコードを読んで挙動を再現したというよりは, Next.js の挙動を観察しながらいくつかの処理を追った程度なので, 正確性は保証しません. 不備などあればぜひ教えてください.

以下各部分の解説です.

メインの処理

メインの処理となるのは handler です. クライアントから送られてきたリクエストを受け取って, origin (S3) に送るリクエストまたはクライアントに戻すレスポンスを返します.

function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // (1) パスが正規化されていなければリダイレクト
  var redirectUri = redirect(uri);
  if (redirectUri !== uri) {
    return {
      statusCode: 308,
      statusDescription: "Permanent Redirect",
      headers: {
        location: {
          value: redirectUri,
        },
      },
    };
  }

  // (2) S3 上のファイルパスに書き換え
  var routeUri = route(uri);
  request.uri = routeUri;
  return request;
}

処理の内容はコメントの通り大まかに 2 つで,

  • (1) パスが正規化されていなければリダイレクトする
  • (2) S3 上のファイルパスに書き換える

です. 前者は必ずしも必須ではないですが, やはり Next.js がこういった挙動をするのと, 同じページに複数の正しい (canonical な) URL が存在するのを防ぐことができます.

パスの正規化

redirect はリダイレクト先として使うためのパスの正規化を行います.

function redirect(uri) {
  // (1) 重複した / を取り除く (/foo//bar/ → /foo/bar/)
  uri = uri.replace(/\/+/g, "/");
  // (2) 末尾の / を取り除く (/foo/bar/ → /foo/bar)
  if (uri !== "/" && uri.endsWith("/")) {
    uri = uri.slice(0, -1);
  }
  return uri;
}

まずは (1) 重複した / を取り除きます. これはやや過剰なおもてなしのような気もしますが, Next.js がこうしているのでやっておきます.

続いて (2) 末尾の / も取り除きます. ただし Next.js の設定を trailingSlash: true に変更している場合はこの限りではないので注意です. オプションで変更できるというのもあってか, 本物の Next.js では重複した / の除去とは別にリダイレクトが行わるようですが, まあここは一回のリダイレクトとしてしまってもよいでしょう.

まとめると, redirect に期待される入出力の例は以下の通りです.

  • /foo/bar//foo/bar
  • /foo/bar.html//foo/bar.html
  • /foo/bar.png//foo/bar.png
  • ////
  • ///foo////bar///foo/bar
  • ///foo////bar.html///foo/bar.html
  • ///foo////bar.png///foo/bar.png

S3 上のファイルパスに書き換え

route は与えられたパスを S3 上のファイルパス, つまり Next.js から export されたファイルのパスに書き換えます.

function route(uri) {
  // (1) /_next/ 以下は触らない
  if (uri.startsWith("/_next/")) {
    return uri;
  }
  // (2) インデックスページにまつわるパスを変換
  if (uri === "/index" || uri.startsWith("/index/")) {
    uri = "/index" + uri;
  } else if (uri === "/") {
    uri = "/index";
  }
  // (3) 必要に応じて拡張子 .html を付与
  var filename = uri.split("/").pop();
  if (!filename.includes(".") || filename.endsWith(".html")) {
    uri = uri + ".html";
  }
  return uri;
}

まず (1) /_next/ 以下は何も変換しません. このディレクトリには Next.js が扱う様々なファイルが含まれていて, やはりある種のパスの解決が必要な部分はあるのですが, そこは Next.js のクライアントサイドのスクリプトが既にうまくやってくれています. 触らぬ神に祟りなしです.

次に (2) インデックスページにまつわるパスの変換をします. 例えば /foo に対応するファイルパスは /foo.html ですが, / に対応するファイルパスは /.html ではなく /index.html です.

ところがこの変換規則だけでは, /index (ページのコンポーネント/pages/index/index.tsx) に対応するファイルも /index.html となってしまい, / と重複してしまいます. これを避けるため, /index に対応するファイルは /index/index.html のように, ファイルパスの先頭にさらに /index が追加されるようになっています. /index/ 以下のページについても同様に処理します.

最後に (3) ファイル名に拡張子 .html を付与します. ここには一つ仮定があって, /pages/foo.png.tsx のような .html 以外の拡張子を持つページは存在しない (アクセスできない) ものとしています. ファイルの存在有無を確認することができればうまくハンドリングできるかもしれませんが, CloudFront Functions からはそういったことはできないので妥協するしかありません.

ところで /public/ 以下に配置された静的ファイルについても言及しておく必要があります. ここに配置されたファイルの扱いは特殊で, 本物の Next.js では /pages/ 以下のページとは区別され, そのままのファイルパスでアクセスできるようになっています.

一方で static export した場合, /public/ 以下のファイルは元のファイルパスを保ちながら, /pages/ から出力されたファイルと同じディレクトリにコピーされます. これではファイルが /public//pages/ どちらに由来するものか原理的に区別ができませんし, もしファイルパスの上で区別できるようになっていたとしても, やはり CloudFront Functions のようにファイルの存在有無が確認できなくてはどうしようもありません.

ということで /public/ 以下のファイルについては以下のような仮定をしておきます.

  • /index/ 以下にファイルは存在しない
  • 拡張子のないファイルや, 拡張子が .html のファイルは存在しない

このように route の実装にはいくつか仮定や妥協が含まれていますが, まあ普通に暮らしていれば困ることはないでしょう...

話を元に戻してここまでをまとめると, route に期待される入出力の例は以下の通りです.

  • //index.html
  • /index/index/index.html
  • /index/foo/index/index/foo.html
  • /foo/index/foo/index.html
  • /foo/index/bar/foo/index/bar.html
  • /foo/bar/foo/bar.html
  • /foo/bar.html/foo/bar.html.html
  • /foo/bar.png/foo/bar.png
  • /_next/foo/bar/_next/foo/bar
  • /_next/foo/bar.html/_next/foo/bar.html
  • /_next/foo/bar.js/_next/foo/bar.js

テスト

テストは CloudFront のコンソールや API からも行えますが, 高速に TDD のサイクルを回したかったので手元で完結するように書いていました. とはいえ CloudFront Functions で使える JS の機能には手元と比べるといくらか制限があるので, 最終的には本物でも動作確認しておくとよいです (それはそう). たとえばうっかり const など書いてしまうと動きません.

偶然そこに node:test があったので使っていますが, そこにあった以上の理由はそんなにないです.

まとめ

あらためてコード全体はこちら.

Next.js の static export はさもそのまま汎用的な静的サーバーで配信できるような見た目をしていますし, 実際のところほとんどの場合はそれで上手くいくのですが, いくつか例外的なケースもあるので気をつけましょう. ちゃんとするならここで紹介したように Next.js の真似をしてやるとよいです (とはいえ普通はそこまで真剣にやらなくても良いと思いますが).

CloudFront Functions は書いていて Atom でカーソルの移動をカスタマイズしたときに近い感覚になりました. 空気みたいな普段の意識の外にあるものに触れてしまうというか...


  1. 正確にはリンクを辿って SPA 的なページ遷移をしている限りはアクセスできるものの, その他の方法 (別のタブで開いたりリロードしたり) ではアクセスできない, という挙動になります.