[AWS CDK] CloudFront Functions でリクエスト URL に index.html を追加する構成を Next.js アプリケーションで試してみた

2024.01.18

こんにちは、CX 事業本部製造ビジネステクノロジー部の若槻です。

前回のエントリで、CloudFront + S3 で静的サイトホスティングをしている Web アプリケーションへのリクエスト URL に index.html を自動追加する構成を作成しました。

そして上記実装が活用できる Web アプリケーションフレームワークとして Next.js があります。

今回は、Next.js アプリケーションでのリクエスト URL に index.html を CloudFront Functions で追加する構成を AWS CDK で構築してみました。

試してみた

モノリポ環境作成の準備

AWS CDK と Next.js を同じリポジトリで管理するために、モノリポ環境を npm workspaces 利用します。ここではその準備を行います。

プロジェクトフォルダを作成します。

mkdir cdk-nextjs-sample && cd $_

Git の初期化を行います。

git init
touch .gitignore
echo "node_modules/" > .gitignore

npm の初期化を行います。

npm init -y

Next.js アプリケーションの初期化

次に Next.js アプリケーションの初期化を、モノリポ環境の packages/web ディレクトリで行います。

ワークスペースを新規作成します。

npm init -w packages/web -y
rm packages/web/package.json

Next.js アプリケーションの初期化を行います。

$ (cd packages/web && npx create-next-app@latest --typescript)
✔ What is your project named? … .
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

next.config.js を設定します。outputexport に設定することで、next build の生成物を out ディレクトリに出力するようにし、AWS CDK でのコンテンツアップロードのソースパスに指定できるようにします。また、trailingSlashtrue に設定し、各ページのファイル名が一律で index.html になるようにします。例えば既定では about.html と出力されるページは、この設定により /about/inde.html に出力されるようになります。

packages/web/next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export", //  `next build` の生成物を `out` ディレクトリに出力する
  trailingSlash: true, // ルート以外のページの出力パスを `out/<Path>/index.html` とする
};

module.exports = nextConfig;

Next.js アプリケーションのページ作成

ルートおよび /about パスのページを作成します。

/about ディレクトリを作成します。

mkdir packages/web/src/app/about

/about/page.tsx ファイルを次の内容で作成します。

packages/web/src/app/about/page.tsx

import Link from 'next/link'

export default function Page() {
  return (
    <>
      <h1>About Us</h1>
      <Link href="/">Back to HOME</Link>
    </>
  )
}

またルートにある page.tsx を次のように修正します。

packages/web/src/app/page.tsx

import Link from 'next/link'

export default function Page() {
  return (
    <>
      <h1>Home</h1>
      <Link href="/about">Go to AboutUs</Link>
    </>
  )
}

これにより両ページ間の遷移を Link タグにより実装できました。

ここでローカルで Web アプリケーションを起動してみます。

npm run dev -w web

http://localhost:3000 にアクセスし、リンクによる遷移が正常に動作することを確認します。

AWS CDK アプリケーションの初期化

次に CDK アプリケーションの初期化を、モノリポ環境の packages/iac ディレクトリで行います。

npm init -w packages/iac -y
rm packages/iac/package.json

CDK Init を実行します。

(cd packages/iac && cdk init sample-app --language typescript)

CDK アプリケーションの依存関係を最新バージョンにアップデートします。

npm i aws-cdk-lib@latest aws-cdk@latest -w iac

AWS CDK コードの実装

AWS CDK のスタックを定義するコードを実装します。

packages/iac/lib/iac-stack.ts

import {
  aws_cloudfront,
  aws_s3,
  aws_s3_deployment,
  aws_cloudfront_origins,
  Stack,
  RemovalPolicy,
  Duration,
  CfnOutput,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class IacStack extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // S3 バケットの作成
    const websiteBucket = new aws_s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // CloudFront から S3 バケットへのアクセスを許可するために、
    // Origin Access Identity を作成し、S3 バケットのアクセスポリシーに追加する
    const originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity'
    );
    websiteBucket.grantRead(originAccessIdentity);

    // CloudFront Function の作成
    const cloudFrontFunction = new aws_cloudfront.Function(
      this,
      'AddSecurityHeadersToTheResponseFunction',
      {
        code: aws_cloudfront.FunctionCode.fromFile({
          filePath: 'src/cloudfront-function/add-index-html-to-request-url/index.js',
        }),
        // JavaScript runtime 2.0 を指定
        runtime: aws_cloudfront.FunctionRuntime.JS_2_0,
      }
    );

    // CloudFront Destribution を作成
    const distribution = new aws_cloudfront.Distribution(this, 'Distribution', {
      errorResponses: [
        {
          ttl: Duration.minutes(5),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/404.html',
        },
      ],
      defaultBehavior: {
        viewerProtocolPolicy:
          aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new aws_cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
        // CloudFront Function と Distribution の関連付け
        functionAssociations: [
          {
            function: cloudFrontFunction,
            eventType: aws_cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
    });

    // CloudFront Distribution のドメイン名を出力
    new CfnOutput(this, 'DistributionUrl', {
      value: `https://${distribution.distributionDomainName}`,
    });

    // S3 バケットへのコンテンツのデプロイ、CloudFront Distribution のキャッシュ削除
    new aws_s3_deployment.BucketDeployment(this, 'WebsiteDeploy', {
      distribution,
      destinationBucket: websiteBucket,
      distributionPaths: ['/*'],
      sources: [aws_s3_deployment.Source.asset("./../web/out")], // Next.js のビルド生成物の出力パスを指定
    });
  }
}

CloudFront Functions コードの実装

リクエスト URL に index.html を追加する CloudFront Functions のコードを実装します。

コードを配置するディレクトリを作成します。

mkdir packages/iac/src
mkdir packages/iac/src/cloudfront-function
mkdir packages/iac/src/cloudfront-function/add-index-html-to-request-url

コードの実装は次のようになります。公式ドキュメントのサンプルコードをそのまま利用しています。

packages/iac/src/cloudfront-function/add-index-html-to-request-url/index.js

async function handler(event) {
  const request = event.request;
  const uri = request.uri;

  // Check whether the URI is missing a file name.
  if (uri.endsWith('/')) {
    request.uri += 'index.html';
  }
  // Check whether the URI is missing a file extension.
  else if (!uri.includes('.')) {
    request.uri += '/index.html';
  }

  return request;
}

既定では .js ファイルが .gitignore の対象になっているため、上記のコードを除外するようにします。

echo "!src/cloudfront-function/**/*.js" >> packages/iac/.gitignore

デプロイ

Next.js アプリケーションをビルドします。

npm run build -w web

これにより out ディレクトリ配下に、index.html および about/index.html が出力されます。

CDK デプロイを実行します。

npm run cdk -w iac -- deploy --require-approval never --method=direct

動作確認

デプロイされた Web アプリケーションにアクセスして動作を確認します。

CloudFront Distribution のドメイン名 https://xxxxxxxx.cloudfront.net にアクセスすると、ルートページが正常に表示されます。リクエスト URL への /index.html の追加処理が行われていることが確認できます。

ルートページから <Link> を利用した /about パスのページへの遷移も正常に動作します。

https://xxxxxxxx.cloudfront.net/about または https://xxxxxxxx.cloudfront.net/about/ に直接アクセスした場合でも、正常にページが表示されます。この場合も /index.html の追加処理が正常に行われていることが確認できました。

その他

index.html の自動追加を行わない場合

CloudFront Functions の関連付け設定を外して、index.html の自動追加を行わない場合の動作を試してみます。

packages/iac/lib/iac-stack.ts

    // CloudFront Destribution を作成
    const distribution = new aws_cloudfront.Distribution(this, 'Distribution', {
      errorResponses: [
        {
          ttl: Duration.minutes(5),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/404.html',
        },
      ],
      defaultBehavior: {
        viewerProtocolPolicy:
          aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new aws_cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
        // // CloudFront Function と Distribution の関連付け
        // functionAssociations: [
        //   {
        //     function: cloudFrontFunction,
        //     eventType: aws_cloudfront.FunctionEventType.VIEWER_REQUEST,
        //   },
        // ],
      },
    });

この場合は / および /about のいずれへのアクセスもパスが存在せずエラーとなります。

正常にアクセスするためにはリクエスト URL でファイル名 index.html まで指定する必要があります。

trailingSlash オプションを設定しない場合

next.config.jstrailingSlash オプションを設定せず、デフォルトのままにした場合の動作を試してみます。

packages/web/next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export", //  `next build` の生成物を `out` ディレクトリに出力する
  //trailingSlash: true, // ルート以外のページの出力パスを `out/<Path>/index.html` とする
};

module.exports = nextConfig;

npm run build -w webnext build)コマンドを実行してアプリケーションをビルドすると、out ディレクトリへの出力は次のようになります。/about/index.html ではなく /about.html となっていることが確認できます。

この場合は今回は CloudFront Functions のコードでは対応できなくなります。ルート以外へのリクエスト URL に .html を付与する処理となるようにコードを修正する必要があります。

おわりに

Next.js アプリケーションでのリクエスト URL に index.html を CloudFront Functions で追加する構成を AWS CDK で構築してみました。

別解として HttpOrigin を利用して S3 ウェブサイトエンドポイントをオリジンにし、無理やり index.html を付与させる方法もありますが、その場合は OAI(または OAC)による認証が使用できないなどの不都合があるので、Next.js アプリケーションを CloudFront + S3 でホスティングする場合は、今回紹介した方法が良いかと思います。

参考

以上