CloudFrontで複数のSPAを配信しようとすることでハマったポイント

# 概要

S3バケットにReactなどで書かれたSPA（Single Page Application）をアップロードし、そのバケットをDistributionのオリジンとして指定し、CloudFrontを通じてウェブサイトを配信するというAWSにおけるウェブサービス鉄板構成ですが、複数のSPAがプロジェクトに存在しかつ同じDistributionで配信したい場合、実はかなりややこしい罠が存在します。

本記事でその罠を紹介し、併せてその回避策を解説します。

# 背景

ユーザ向け画面と管理者画面が分けられるウェブアプリを開発していましたが、ついSPA二つ作ってしまいました（後によく考えたら完全に不要ですが）。もちろん、開発上の都合や、要件的にそうしないといけませんなどの理由で、同じアプリケーション内に複数SPAを入れる場合もあるでしょう。

# 結論

まず、複数SPAを持つことをできるだけ避けましょう！一つのSPAを含んだバケットをCloudFrontのデフォルトオリジンとして設定しておけば、ほぼすべての罠が回避できます。

どうしても複数SPAを持ちたい場合、CloudFront FunctionかLambda@EdgeでURIを書き換えるなどの回避策はありますが、副作用を考えた上でそれらをご活用ください～

コードはこちら：
https://github.com/zhang-hang-valuesccg/MultiSPAinOneDistribution

# 事前準備

CDKとSPA二つ用意します（ついてにnpm installやビルドも行います）：
```bash:
cdk init -l typescript
npm creat vite@latest pageA -- --template react-ts
npm creat vite@latest pageB -- --template react-ts
npm i && cd pageA && npm i && ../pageB && npm i && cd ..
cd pageA && npx vite build && cd ../pageB && npx vite build
```

ちょっとSPAを書き換えて区別できるように、PageAとPageBという文字を入れます：

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/a827c5f8-b542-c055-decd-153c75cdd608.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/f807229d-2f13-2a71-7cbe-00af3bec3383.png">


現時点のディレクトリはこんな感じ：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/93458e8d-2ef7-17b5-f725-58471eae3a70.png)

CDKでバケットとDistribution作ります。ちなみに、使っているCDKは2.162.0ですが、少し前のバージョンからようやくOACでCloudFrontのS3アクセスを制御（現時点の推奨プラクティス）できるようになりましたので本記事ではそのやり方を採用します。（参考：[AWSドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)）


```typescript:multi_sp_ain_cloud_front-stack.ts
// import everything...

export class MultiSPAinCloudFrontStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const bucketA = new Bucket(this, "bucket-pagea", {
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const bucketB = new Bucket(this, "bucket-pageb", {
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const distribution = new Distribution(this, "distribution-multi-spa", {
      enableLogging: true,
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucketA),
        cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
      },
      },
    });

    new BucketDeployment(this, "deploy-pagea", {
      sources: [Source.asset("./pageA/dist")],
      destinationBucket: bucketA,
      distribution: distribution,
      distributionPaths: ["/*"],
    });

    new BucketDeployment(this, "deploy-pageb", {
      sources: [Source.asset("./pageB/dist")],
      destinationBucket: bucketB,
      distribution: distribution,
      distributionPaths: ["/*"],
    });
  }
}

```

CDKにあんま馴染みのない人のために解説しますが、二つのSPAがあるので二つのバケットを作って、それぞれPageAとPageBの静的ファイルを入れてきます。Distributionも作って、デフォルトオブジェクトにindex.htmlを指定して、デフォルトオリジンにまずPageAのバケットを指定します。

```bash:
npx tsc
cdk deploy --all
```

でAWSにデプロイします。ここまでごく一般的なやり方でAWSでのウェブサービスホスティング経験がある人なら理解しやすいと思います。

CloudFrontのドメインにアクセスすれば、PageAが思い通り表示されましたね！めでたしめでたし～
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/78084af7-48bb-ccaf-3315-43d19a23aade.png)

…PageBは？

# ハマりポイント

そうですね…PageBも足しておかないと！

```diff_typescript:multi_sp_ain_cloud_front-stack.ts
// …
    const distribution = new Distribution(this, "distribution-multi-spa", {
      enableLogging: true,
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucketA),
        cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
      },
      },
+     additionalBehaviors: {
+       "/pageb*": {
+         origin: S3BucketOrigin.withOriginAccessControl(bucketB),
+         cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
+     },
+       },
+     },
    });

// …
```

デフォルトオリジンでPageAを置きましたので、additional behaviorになりますが、無事PageBも足しておきました！えい！デプロイ！

前回同様、CloudFrontのドメインをアクセスしたらPageAが出てきました！
で、/pagebを足すと…

![スクリーンショット 2024-10-11 220612.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/c3b164e9-9cfe-0ada-22d4-4788298c0354.png)

なぜか出なくなりましたね。デフォルトの方でindex.htmlを指定したのでこちらで手動/pageb/index.html指定しても、結果は同様でした。


# 原因

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/5f5cc721-bfe4-fc49-91e5-45d88d5ae470.png)

リクエストを確認したところ、どうやら/pageb/index.htmlへのアクセスはそもそもCloudFrontの想定外らしいですね。もっと深掘りますと、同じパスがPageBのバケット内にもあるようにCloudFrontが思い込んでいます。

:::note warn
こうなるんだと我々は思うかもしれんが
**/pageb/index.html: バケット（PageB）→index.html**
:::

:::note info
CloudFrontはこう思っているんだ！
**/pageb/index.html: バケット（PageB）→ pageb/ -> index.html**
:::



ふむ…じゃあこうしたらどうかな？元々ルートフォルダにある静的ファイルをpagebというフォルダの中に入れました！

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/e718040a-15fd-17a7-11dc-67a890cbdbd5.png">

CDKでやるとこういう感じでprefixを足しておきます：
```diff_typescript:multi_sp_ain_cloud_front-stack.ts
    new BucketDeployment(this, "deploy-pageb", {
      sources: [Source.asset("./pageB/dist")],
+     destinationKeyPrefix: "pageb/",
      destinationBucket: bucketB,
      distribution: distribution,
      distributionPaths: ["/*"],
    });
```

ここで面白いことが起こりました：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/744ae64c-cbda-3e04-0ed9-3bd9ffccf32e.png)

pageAはいつもの通りアクセスできます。pageBのindex.htmlもちゃんと見つかりましたね。筆者の予想通り、/index.htmlじゃなく/pageb/index.htmlをCloudFrontが求めています。が、.jsと.cssの挙動が怪しいです！

![スクリーンショット 2024-10-11 224813.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/5f5ab158-ab39-6259-b11b-1d488db9423c.png)

よくよく見ると、バケット（PageB）のpageb/assets/xxxx.jsじゃなくて、なんと！バケット（PageA）の/assets/xxxx.jsを参照しようとしています！PageAとPageBの静的ファイルの.cssの名前は一緒なのでPageBの.cssがバケット（PageA）でも見つかっちゃったわけです。

つまり、URIにもある通り、/pageb/assets/...に見に行くわけではなく、/assets/...（デフォルトオリジン）へリクエストが行っているのです。



# 回避策

そこで、一個目の回避策を思いつきました。/pageb/index.html以外のものはバケット（PageA）を参照するつもりなら、そうしてあげましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/1e34ab64-2a29-efdb-0c64-c4ad4e61e8b8.png)
(CDKでBucketDeploymentのpruneフラグでアセットたちを共存させようとしましたが、できませんでした。なぜでしょうか？とりあえず手動で両方のアセットをアップロードしました)

PageBのアセットをバケット（PageA）のassets/フォルダにアップロードしますとPageBもちゃんとアセットにアクセスできるようになります！

![スクリーンショット 2024-10-12 005452.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/39e93094-44c3-bb39-bff4-0db2520a55b7.png)

でもこうなっちゃうとやはりモヤモヤが残っています。同じSPAなのにhtmlとassetがバラバラですし、アセットたちが同じフォルダに混在してしまいますし、事情知らない人（運用者、後継者など）にとって迷惑でしかありません。

筆者としては、これ以上の解決策を見出せず結局AWSサポートに頼りました…
ここからはAWSサポートのご意見を基づいた回避策のご紹介です。

## 考え方
今の課題を一旦整理しましょう：二つのバケットにそれぞれSPAを配置し、CloudFront側でその中の一バケット（ここではPageAを含んだバケット）をデフォルトオリジンにし、もう一つのバケット（PageB）をadditional behavior（/pageb）として取り込みましたが、CloudFrontがうまくindex.html以外のバケット（PageB）のオブジェクトを取得てきていません。

| Referer |        URI          |      destination     |
|:-----:|:----------------------|:---------------------|
| PageA | /(index.html)         | BucketA: ~/          |
| PageA | /assets/xxx.js        | BucketA: ~/assets    |
| PageA | /assets/xxx.css       | BucketA: ~/assets    |
| PageB | /pageb/index.html     | **BucketB: ~/pageb/**|
| PageB | /assets/xxx.js        | **BucketA: ~/assets**|
| PageB | /assets/xxx.css       | **BucketA: ~/assets**|

テーブルが示している通り、結局バケット（PageB）にはリクエストが行きませんね。しかもPageAもPageBもアセット取得する時URIが一緒になっています。となると、
- まず、バケット二つは要りません、どうせアセットが同じバケット内にないといけないから
- PageAとPageBの区別がつくようにうまくURIを書き換えればいいかも？
- つまりこうなると目指します：

| Referer |        URI          |      destination           |
|:-----:|:----------------------|:---------------------------|
| PageA | /(index.html)         | Bucket: ~/                 |
| PageA | /assets/xxx.js        | Bucket: ~/assets           |
| PageA | /assets/xxx.css       | Bucket: ~/assets           |
| PageB | /pageb/index.html     | Bucket: ~/pageb/           |
| PageB | **/pageb/assets/xxx.js**  | **Bucket: ~/pageb/assets** |
| PageB | **/pageb/assets/xxx.css** | **Bucket: ~/pageb/assets** |

## 方法

こうするために、CloudFront FunctionかLambda@Edgeが必要となります。ビューワーリクエストかオリジンリクエストで関数を配置し、Refererヘッダーに基づいてURIを随時書き換えれば、リクエストをほしいところに導くことができます！

本記事では、手軽さを取ってCloudFront Functionをビューワーリクエストで配置する方法にしましたが、Lambda@Edgeをオリジンリクエストに配置しても同様な効果が得られるはずです。

まずは以下のようにCloudFront Functionを書きます。RefererヘッダーがPageBである（正規表現で検証）のときに、URIの先頭に/pagebがついていることを確保します。

```javascript:index.js
// cloudfront fn
function handler(event) {
  let request = event.request;
  const uri = request.uri;
  const headers = request.headers;

  const regex_pageb = /^https:\/\/.*\/pageb/;

  if (headers.referer && regex_pageb.test(headers.referer.value)) {
    if (!request.uri.startsWith("/pageb")) {
      request.uri = "/pageb" + request.uri;
    }
  }

  return request;
}
```
インフラ側でDistributionにCloudFront Functionをデフォルトオリジンにアタッチし、PageBの静的ファイルを~/pageb/にアップロードします。


```diff_typescript:multi_sp_ain_cloud_front-stack.ts
// ...
+   const cloudfrontfn = new Function(this, "cloudfront-fn", {
+     code: FunctionCode.fromFile({ filePath: "./cloudfrontfn/index.js" }),
+     runtime: FunctionRuntime.JS_2_0,
+   });

    const distribution = new Distribution(this, "distribution-multi-spa", {
      enableLogging: true,
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucketA),
        cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
+       functionAssociations: [
+         {
+           eventType: FunctionEventType.VIEWER_REQUEST,
+           function: cloudfrontfn,
+         },
+       ],
      },
-     additionalBehaviors: {
-       "/pageb*": {
-         origin: S3BucketOrigin.withOriginAccessControl(bucketB),
-         cachePolicy: CachePolicy.CACHING_DISABLED, // turn off cache for verification
-       },
-     },
    });

    new BucketDeployment(this, "deploy-pagea", {
      sources: [Source.asset("./pageA/dist")],
      destinationBucket: bucketA,
      distribution: distribution,
      distributionPaths: ["/*"],
    });

+   new BucketDeployment(this, "deploy-pageb", {
+     sources: [Source.asset("./pageB/dist")],
+     destinationKeyPrefix: "pageb/",
+     destinationBucket: bucketA,
+     distribution: distribution,
+     distributionPaths: ["/*"],
+   });

-   new BucketDeployment(this, "deploy-pageb", {
-     sources: [Source.asset("./pageB/dist")],
-     destinationBucket: bucketB,
-     distribution: distribution,
-     distributionPaths: ["/*"],
-   });
// ...
```

## 効果
デフォルトドメインにアクセスすると、PageAが従来通り表示できます。
![スクリーンショット 2024-10-12 152547.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/e49e64c0-c213-0caf-f136-4d9e59a154da.png)
/pageb/index.htmlにアクセスすると、PageBも表示できます！
![スクリーンショット 2024-10-12 152602.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3824949/e9ce6d0a-a52f-1fab-f3eb-374c467ce7ac.png)

もちろん、デフォルトオブジェクトを使わずにPageAへのアクセスも関数で制御するとか、PageBへのアクセスでindex.htmlをURIに足すとか、関数の導入でできることも結構あります。二つだけでなく、三つ、四つのSPAも同じバケット内に配置し、アクセスできるようになれるはずです。

# 最後に

シングルバケット＋関数制御で複数SPAをシングルDistributionで配信することができました！が、以下のデメリットも考えられます：
- 管理するリソースが増える：CloudFront Functionはまだしも、Lambda@Edgeはus-east-1に位置しないといけないので場合によっては望ましくないかもしれません（そのためにCDKでもう一つスタック作らないといけませんね）
- URI処理自体がややこしい場合もある：CloudFront FunctionとLambda@Edgeでベーシック認証を配置したり、ヘッダーを書き換えたりする人もいるでしょう。こうなるとURI処理との干渉も考えないといけなくなり、処理自体が分かりにくくなる可能性もあります。筆者個人としても、CloudFront FunctionやLambda@Edgeに複雑な仕事を任せたくありません

また、バケットの静的ウェブサイトエンドポイントを作ってそのエンドポイントをDistributionのオリジンとして指定する手もあると思いますが、現時点ではややレガシーと考えられているので本記事では割愛します。

あくまで回避策ですが、お役立にてると嬉しいです！またこの課題に対してもっといいやり方あるよ！の人はぜひコメント欄で教えてください！