Next.jsのSSGサイトをS3に静的ホスティングした際に起きる末尾スラッシュ404問題を低コストで解決した

# 前段

[Next\.*でSSGした静的サイトを（CSR後リロード時404問題を最小コストで解決しながら）S3でホスティングする \- Qiita](https://qiita.com/ryokkkke/items/1478a022b220bf6e61ff)の続きです。

この前回の問題を要約すると、

- `next export`をすると、`pages/`配下のコンポーネントは、`.html`拡張子がついて書き出される。
    - `pages/hoge.tsx`であれば`/hoge.html`
- しかし実際にCSRすると（CSR自体は成功するが）URLは`/hoge`になる。
- S3は`/hoge`と`/hoge.html`を別のURLとして認識するので、CSR後にURlが`/hoge`の状態でリロードすると404になる。
    - というか本来それらは別で、Webサーバがいい感じに同じルーティングとして扱ってくれたりするサービスもあるのだけど、S3の静的ホスティングにはそういう機能が無い。

解決策として、ビルド後の`.html`ファイルから、拡張子を消してS3にアップロードしました。

# 本題の概要

さて、ここまでは良かったんですが、この状態で新たに**URLの末尾にスラッシュをつけると404になるという問題が発生しました。**
例えば`/hoge`であれば`/hoge/`は404になります。

これは、`/hoge/`というパスでアクセスされた場合、実際はS3が表示しようとするのは`/hoge/index.html`だからです。
そんなファイルは無いので404になります。

じゃあ`/hoge/index.html`足せば良いんじゃないの？って気がするんですが、問題は、`/hoge`（ファイル）と`/hoge/`（ディレクトリ）がファイルシステム上同じディレクトリ配下に共存できないということです。
つまり、**前段で`/hoge`（拡張子なし）というファイルを作ってしまっているので、当然ながら同じ階層に`/hoge/`というディレクトリを作ることは出来ません。**

でもスラッシュついてても同じページを表示して欲しいですよね。

※実際にはNext使ってない場合も`.html`のないHTMLファイルがある場合は起きる問題だと思います。

今回に関しては、**ちょっとハック的な方法で解決しているのでご注意ください。**
あと不明点もありつつ実現しています。
本番環境ではそういった事情を理解した上で使ってください〜
（なので、知識として一般形にせずやったことの共有という意味でタイトルを過去形にしています。）

# 対応

`/hoge`と`/hoge/`が共存できないと言いましたが、**S3では共存出来ます。**
S3の「フォルダ」というのは実際にはフォルダではなく、UI上そう見せかけているだけだからですね。

参考：[Amazon S3における「フォルダ」という幻想をぶち壊し、その実体を明らかにする \| Developers\.IO](https://dev.classmethod.jp/articles/amazon-s3-folders/)

ということで方針は以下の感じ。

1. `next export`の結果（`.html`削除済み）をS3にアップロード後、**S3上で`/hoge/index.html`オブジェクトを作成する。**
2. その`index.html`オブジェクトに対して**末尾スラッシュなしのURLへのリダイレクトを設定する。**

ただし一つ注意があって、この対応策を実現できたのは、独自ドメインを使用していて、かつ`www.`サブドメインつきのURLを、APEXドメイン（サブドメなしのドメイン）のURLにリダイレクトさせていたからでした。
（つまり `https://www.my-domain.com/*`のアクセスは`https://my-domain.com/*`にリダイレクトされるようにしていた）
もう少し厳密に言うと、**静的ホスティングしているバケットに結果的にルーティングされるドメインが複数存在する場合に使えるはずです。**

今回も手動でやるのは厳しいので、引き続きシェルスクリプトで書きます。
前回の記事のスクリプトの内容直後から始まるイメージです。

## スクリプト

:arrow_down: 空のindex.htmlを用意して、これをコピーしていきます。

```shell
# コピー用の空の index.html を作成
empty_index_html_directory=./out/tmp/
empty_index_html=${empty_index_html_directory}index.html
mkdir -p $empty_index_html_directory
touch $empty_index_html
```

:arrow_down: 追加する index.html のパスリストを作成します。

```shell
# 追加する index.html のパスリストを作成
# `$html_files`には`.html`拡張子を削る前の対象ファイルのパスが全て含まれています（前記事参照）。
target_index_html_paths=()
for filepath_with_extension in $html_filepaths; do
  filepath=${filepath_with_extension%\.html}

  index_html_path=$filepath/index.html
  target_index_html_paths+=( $index_html_path )
done
```

:arrow_down: 上の二つの素材を使って実際にS3にオブジェクトを作成

```shell
# Location ヘッダがついた空の index.html オブジェクトを作成
for index_html_path in $target_index_html_paths; do
  key=${index_html_path#\.\/out\/}

  # ここのドメインをバケットに当てられているドメインとは別のドメインにすることが重要。
  # 同じドメインを指定すると正常にリダイレクトできない。
  # この値は、例えば `https://my-domain.com/hoge` のように、末尾に`/index.html`がないURLになる。
  destination=https://www.my-domain.com/${key%\/index.html}

  aws s3api put-object \
    --bucket $bucket_name \
    --body $empty_index_html \
    --key $key \
    --website-redirect-location $destination
done
```

ここでポイントなのは`--website-redirect-location`と、`$destination`の部分に`www.`つきのURLを指定しているところです。

### Website-Redirect-Location

S3のオブジェクトにはメタデータを付与することができますが、その中でも`Website-Redirect-Location`はリダイレクト先を指定するものです。

### リダイレクト先に、バケットに当てられているドメインとは別のドメインを指定する

`Website-Redirect-Location`の値として指定している`$destination`ですが、ポイントは**バケットに当てられているドメインとは別のドメインにすること**です。

S3に独自ドメインを当てる場合、バケット名をそのドメインの名前にする必要があります。
この`$destination`に指定するドメインは、そのバケット名とは別のドメインということです。

### 同じドメインを当てるとリダイレクトが上手く行かない:cry:

なぜかというと、**リダイレクト先をバケット名と同じドメインにすると、なぜか実際のリダイレクト先がカスタムドメインの方ではなくS3のドメインになってしまうからです:innocent:**

例えば今回の静的ホスティングしているドメインとバケット名が`my-domain.com`だとすると、`Website-Redirect-Location: https://my-domain.com/hoge`（ドメインが同じ）を指定した場合、実際にリダイレクトされるのは`http://my-domain.com.s3-website-ap-northeast-1.amazonaws.com/hoge`になります。

...なぜ...？
これよくわかっていないので、分かる方いらっしゃったら教えてください...:pray:

そもそも`Website-Redirect-Location`ってHTTPの`Location`ヘッダそのものではないんですよね。
これをリダイレクト時にS3（もしくは前段に置いているCloudFront？）が中で何らかの処理をかけて`Location`ヘッダに変換しているのだと思いますが、同一バケットの場合は何らかの処理が入るのでしょうね、カスタムドメインが外れます...
ネットで調べても、今回発生している結果の理解の助けになるような記述を発見できませんでした。

とりあえず、ドメインが別であれば動きます。

## 結果的なリダイレクト状況

これでどういう状態になるかというと、S3のオブジェクトは

- `my-domain.com`（バケット）
    - `/hoge`
    - `/hoge/index.html`

こういう感じになり、`/hoge/index.html`というオブジェクトのリダイレクト先は`https://www.my-domain.com/hoge`になります。

実際にアクセスすると、以下のようなリダイレクトを辿るようになりました。

```
0. `https://my-domain.com/hoge/`    <- ここにユーザーがアクセス、`/hoge`と同じルーティングになって欲しい。
1. `https://www.my-domain.com/hoge` <- `Website-Redirect-Location`での指定通り`www.`つきのURLに飛ぶ。
2. `http://my-domain.com/hoge`      <- `www.`つきのURLが一度`http`に向けられるが、`https`へのリダイレクトをかけている。
3. `https://my-domain.com/hoge`     <- たどり着いた！
```

このように3回のリダイレクトを通して目的のURLにたどり着いています。

# 最終的なスクリプト


```shell
# S3にアップロードする処理がある（内容は前段の記事参照）
# 〜省略〜
aws s3 sync ./out/ s3://$bucket_name/ --delete # などなど
# 〜省略〜


# コピー用の空の index.html を作成
empty_index_html_directory=./out/tmp/
empty_index_html=${empty_index_html_directory}index.html
mkdir -p $empty_index_html_directory
touch $empty_index_html

# 追加する index.html のパスリストを作成
# `$html_files`には`.html`拡張子を削る前の対象ファイルのパスが全て含まれています（前記事参照）。
target_index_html_paths=()
for filepath_with_extension in $html_filepaths; do
  filepath=${filepath_with_extension%\.html}

  index_html_path=$filepath/index.html
  target_index_html_paths+=( $index_html_path )
done

# Location ヘッダがついた空の index.html オブジェクトを作成
for index_html_path in $target_index_html_paths; do
  key=${index_html_path#\.\/out\/}

  # ここのドメインをバケットに当てられているドメインとは別のドメインにすることが重要。
  # 同じドメインを指定すると正常にリダイレクトできない。
  # この値は、例えば `https://www.my-domain.com/hoge` のように、末尾に`/index.html`がないURLになる。
  destination=https://www.my-domain.com/${key%\/index.html}

  aws s3api put-object \
    --bucket $bucket_name \
    --body $empty_index_html \
    --key $key \
    --website-redirect-location $destination
done
```

# おしまい

ちょっとハック的な感じあるのと、S3（かもしくはCloudFront）の中の仕様がはっきりわかっていない状態でやっているので、何らかのタイミングで動かなくなるとかあるのかなあとか思ったり:thinking_face:

ともあれ、これでひとまずNextのSSGをS3にホスティングするのは完了かな。

ただ、現代であればそもそもVercelとかNetlifyとかAmplifyコンソールとかを使う方が良いと思うので、まずはそちらの使用検討をオススメします。