🚀

2024年から始めるAngular Universal (SSR) の本番運用

2023/12/22に公開

この記事は、Angular Advent Calendar 2023の22日目の記事です。

Angular Universalとは?

https://github.com/angular/universal

Angular Universalは、AngularでServer Side Renderingを実現するための追加ライブラリでした。
というのは、Angular v17からAngular UniversalがAngular CLIに統合され、より簡単にSSR対応アプリを作成できるようになったためです。

In version 17, Universal has been moved into the Angular CLI repo. Code has been refactored and renamed (mostly under @angular/ssr now), but the core functionality and architecture is unchanged.

以下のコマンドを用いて新規プロジェクトを作成するだけで、SSR対応のAngularプロジェクトを構築することができます。

$ ng new --ssr

以前もまあそこまで難しくはなかったのですが、それにも増してより簡単になりました。

今回やること

2019年のアドベントカレンダーにて、当時のAngular Universalを使ってSSR対応アプリをLambdaにデプロイしてみる、という記事を書きました。

https://qiita.com/seapolis/items/c20672777d90cb4a1ba5

あれから4年の歳月を経て新しくなったAngular Universalを、当時と同じようにLambdaにデプロイして、本番運用に耐えうるかを検証してみたいと思います。

今回作成したサンプルコードは以下のリポジトリにて公開しています。

https://github.com/kaito3desuyo/angular-v17-ssr-example

実際にLambdaにデプロイしたアプリケーションはこちら。

https://laxn35pwvycnbzvuu3hzeq4hrq0qlztb.lambda-url.ap-northeast-1.on.aws/

作業

初期設定

ということで、先程のコマンドを使って、Angular v17からリニューアルされた初期アプリを作っていきたいと思います。

$ ng new --ssr
$ npm run build && npm run serve:ssr:<application name>

http://localhost:4000で起動します。

爆速初期アプリの図

早速Lighthouseを回してみました。FCPがちょっと遅いですが、それでもパフォーマンス96点は、今までのSPA Angularの感覚からすると驚異の数字です。。。

中身を見てみる

SPA Angularとの違いとして目につくのは、以下のファイルが存在することです。

  • server.ts
  • src/main.server.ts
  • src/app/app.config.server.ts

下の2ファイルは、CSRとSSRで設定を変えるための数行のファイルでした。特に弄る必要はなさそうなので、残りのserver.tsを見てみましょう。

server.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';

// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  const indexHtml = join(serverDistFolder, 'index.server.html');

  const commonEngine = new CommonEngine();

  server.set('view engine', 'html');
  server.set('views', browserDistFolder);

  // Example Express Rest API endpoints
  // server.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('*.*', express.static(browserDistFolder, {
    maxAge: '1y'
  }));

  // All regular routes use the Angular engine
  server.get('*', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;

    commonEngine
      .render({
        bootstrap,
        documentFilePath: indexHtml,
        url: `${protocol}://${headers.host}${originalUrl}`,
        publicPath: browserDistFolder,
        providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
      })
      .then((html) => res.send(html))
      .catch((err) => next(err));
  });

  return server;
}

function run(): void {
  const port = process.env['PORT'] || 4000;

  // Start up the Node server
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

run();

SSRで使用するフレームワークは昔と変わらずExpressのようです。
ということは、そこまで頑張らなくてもLambdaに載せられそうです。

The Express app is exported so that it can be used by serverless Functions.

わざわざ「サーバーレス関数に使用できるようにExpress APPインスタンスをエクスポートしてるよ!」とご丁寧に書いてくれています。優しい!

Lambdaに載せてみる

Serverless Frameworkの設定

まずはServerless Framework周りのライブラリを入れていきましょう。

$ npm i -D serverless serverless-deployment-bucket @serverless/typescript ts-node
$ npm i @h4ad/serverless-adapter

次に、serverless.tsを作成します。

serverless.ts
import type { AWS } from '@serverless/typescript';

const system = 'angular-v17-ssr-example';

const serverlessConfiguration: AWS = {
  service: system,
  frameworkVersion: '3',
  plugins: ['serverless-deployment-bucket'],
  provider: {
    name: 'aws',
    stage: 'prod',
    region: 'ap-northeast-1',
    runtime: 'nodejs20.x',
    architecture: 'arm64',
    stackName: '${param:prefix}-cfstack',
    stackTags: {
      System: system,
      Stage: '${sls:stage}',
      Serverless: 'true',
    },
    deploymentBucket: {
      name: '${param:prefix}-sls-deployment-bucket',
      serverSideEncryption: 'AES256',
    },
    iam: {
      role: {
        name: '${param:prefix}-lambda-role',
      },
    },
    environment: {},
  },
  // import the function via paths
  functions: {
    app: {
      handler: `dist/${system}/server/server.main`,
      name: '${param:prefix}-lambda',
      memorySize: 1769,
      timeout: 60,
      url: true,
      package: {
        patterns: ['!**', 'dist/**'],
      },
    },
  },
  package: { individually: true },
  params: {
    default: {
      prefix: system,
    },
  },
  resources: {},
};

module.exports = serverlessConfiguration;

Lambdaにデプロイするためには、Angular CLIでビルドした成果物をパッケージングしてもらうための対象ファイル設定と、エントリーポイントを指定する設定が最低限必要です。
そのあたりが以下です。

serverless.ts
<…前略>
  functions: {
    app: {
      handler: `dist/${system}/server/server.main`,
      <…中略>
      url: true,
      package: {
        patterns: ['!**', 'dist/**'],
      },
    },
  },
<…後略>

Angular CLIでビルドした成果物は、dist/<application name>以下に生成され、SSR用のmjsファイルはserver/server.mjsという名前で出力されるため、handlerプロパティにその通り設定します。
.mainは関数名です。詳細は後述します。

また、distフォルダ内以外のファイルを含まないように、packageプロパティでパターンフィルタリングをかけています。

今回はLambda Function URLでアクセスできることを目的としているため、urlプロパティもtrueに設定しておきます。

その他のプロパティは適宜編集して構いません。

Expressインスタンス作成部を分離する

server.tsの内部で、Expressの組み込みサーバーを起動する処理が書かれています。
今回、@h4ad/serverless-adapterを使ってLambdaの作法に則ったプロキシ処理を行うため、実は組み込みサーバーは必要ありません。
ただし、Expressのインスタンスだけ必要なので、これをapp.tsとして分離していきます。

app.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';

// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  const indexHtml = join(serverDistFolder, 'index.server.html');

  const commonEngine = new CommonEngine();

  server.set('view engine', 'html');
  server.set('views', browserDistFolder);

  // Example Express Rest API endpoints
  // server.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('*.*', express.static(browserDistFolder, {
    maxAge: '1y'
  }));

  // All regular routes use the Angular engine
  server.get('*', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;

    commonEngine
      .render({
        bootstrap,
        documentFilePath: indexHtml,
        url: `${protocol}://${headers.host}${originalUrl}`,
        publicPath: browserDistFolder,
        providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
      })
      .then((html) => res.send(html))
      .catch((err) => next(err));
  });

  return server;
}
server.ts
import { app } from './app';

function run(): void {
  const port = process.env['PORT'] || 4000;

  // Start up the Node server
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

run();

コピペするだけでOKです。

handler.tsを作成する

次に、Lambdaからのエントリーポイントとなるhandler.tsを作成していきます。

handler.ts
import { ServerlessAdapter, ServerlessHandler } from '@h4ad/serverless-adapter';
import { ApiGatewayV2Adapter } from '@h4ad/serverless-adapter/lib/adapters/aws';
import { ExpressFramework } from '@h4ad/serverless-adapter/lib/frameworks/express';
import { LazyFramework } from '@h4ad/serverless-adapter/lib/frameworks/lazy';
import { DefaultHandler } from '@h4ad/serverless-adapter/lib/handlers/default';
import { PromiseResolver } from '@h4ad/serverless-adapter/lib/resolvers/promise';
import { Express } from 'express';
import { app } from './app';

const framework = new LazyFramework(new ExpressFramework(), async () => app());

export const main: ServerlessHandler<Express> = ServerlessAdapter.new(null)
  .setFramework(framework)
  .setHandler(new DefaultHandler())
  .setResolver(new PromiseResolver())
  .addAdapter(new ApiGatewayV2Adapter())
  .build();

ここでエクスポートしたmain関数が、Lambdaのエントリーポイントとなります。
serverless.ts.mainの部分ですね。

Lambda Function URLのイベントは、API Gateway V2と同様の形式ですので、ApiGatewayV2Adapterを使用することで動作します。

@h4ad/serverless-adapterは、有名な@vendia/serverless-expressの後継を目指しているライブラリで、Express以外にもFastifyやhapi、Koa、NestJS等の著名なJS/TSフレームワークで動作するよう設計されています。
https://serverless-adapter.viniciusl.com.br/

Exampleも豊富にあるので参考にしてみてください。
https://github.com/H4ad/serverless-adapter-examples/

angular.jsonを編集する

SSR時のエントリーポイントを指定するプロパティがあるので、projects.<application name>.architect.build.configurationsに新たに項目を追加し、エントリーポイントをhandler.tsに変更します。

angular.json
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "angular-v17-ssr-example": {
      <…中略>
      "architect": {
        "build": {
          <…中略>
          "configurations": {
            <…中略>
            "serverless": {            <-- ここから
              "ssr": {
                "entry": "handler.ts"
              }
            }                          <-- ここまで
          },
          "defaultConfiguration": "production"
        },
        <…中略>
      }
    }
  }
}

tsconfig.jsonを継承したtsconfig.sls.jsonを作成する

Angular v17では、デフォルトでES Modulesが使われていますが、Serverless FrameworkではまだESM非対応のため、compilerOptions.moduleを上書きしたtsconfig.sls.jsonを作成します。

tsconfig.sls.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs"
  }
}

また、既存のtsconfig.app.jsonで指定されているfilesapp.tshandler.tsを足しておきましょう。

tsconfig.app.json
<…前略>
  "files": [
    "src/main.ts",
    "src/main.server.ts",
    "server.ts",
    "app.ts",
    "handler.ts"
  ],
<…後略>

package.jsonにコマンドを追加する

先程angular.jsonに追記したserverlessという名前の設定を使用して、ビルドしてからデプロイが実行されるようにします。

また、serverlessコマンド実行時は、先ほど作成したtsconfig.sls.jsonを使って実行されるよう、TS_NODE_PROJECTで明示的に指定します。

package.json
<…前略>
  "scripts": {
    <…中略>
    "serverless": "TS_NODE_PROJECT=tsconfig.sls.json serverless",
    "deploy": "npm run build -- --configuration serverless && npm run serverless -- deploy",
    "remove": "npm run serverless -- remove"
  },
<…後略>

デプロイしてみる

ここまでの作業で、Lambdaをデプロイできるようになりました。
実際にコマンドを叩いてみます。

$ npm run deploy
> angular-v17-ssr-example@0.0.0 deploy
> npm run build -- --configuration serverless && npm run serverless -- deploy


> angular-v17-ssr-example@0.0.0 build
> ng build --configuration serverless

⠏ Building...
▲ [WARNING] Module '@h4ad/serverless-adapter' used by 'handler.ts' is not ESM

  CommonJS or AMD dependencies can cause optimization bailouts.
  For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies


▲ [WARNING] Module '@h4ad/serverless-adapter/lib/adapters/aws' used by 'handler.ts' is not ESM

  CommonJS or AMD dependencies can cause optimization bailouts.
  For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies


▲ [WARNING] Module '@h4ad/serverless-adapter/lib/frameworks/express' used by 'handler.ts' is not ESM

  CommonJS or AMD dependencies can cause optimization bailouts.
  For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies


▲ [WARNING] Module '@h4ad/serverless-adapter/lib/frameworks/lazy' used by 'handler.ts' is not ESM

  CommonJS or AMD dependencies can cause optimization bailouts.
  For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies


▲ [WARNING] Module '@h4ad/serverless-adapter/lib/handlers/default' used by 'handler.ts' is not ESM

  CommonJS or AMD dependencies can cause optimization bailouts.
  For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies


▲ [WARNING] Module '@h4ad/serverless-adapter/lib/resolvers/promise' used by 'handler.ts' is not ESM

  CommonJS or AMD dependencies can cause optimization bailouts.
  For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies

Prerendered 1 static route.


Initial Chunk Files | Names         |  Raw Size | Estimated Transfer Size
main.js             | main          | 204.23 kB |                56.53 kB
polyfills.js        | polyfills     |  32.69 kB |                10.59 kB
styles.css          | styles        |   0 bytes |                 0 bytes

                    | Initial Total | 236.92 kB |                67.12 kB

Application bundle generation complete. [3.698 seconds]

> angular-v17-ssr-example@0.0.0 serverless
> TS_NODE_PROJECT=tsconfig.sls.json serverless deploy


Deploying angular-v17-ssr-example to stage prod (ap-northeast-1)
Using deployment bucket 'angular-v17-ssr-example-sls-deployment-bucket'
Using deployment bucket 'angular-v17-ssr-example-sls-deployment-bucket'

✔ Service deployed to stack angular-v17-ssr-example-cfstack (50s)

endpoint: https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/
functions:
  app: angular-v17-ssr-example-lambda (801 kB)

Need a faster logging experience than CloudWatch? Try our Dev Mode in Console: run "serverless dev"

ESM非対応エラーが出ますが、正常にデプロイできます。
最後に表示されたendpointのURLにアクセスすると、初期アプリ画面が表示されます。

爆速初期アプリ on Lambdaの図

本番運用で考慮したいこと

このままでも動作はしますが、本番運用時は以下のことを考慮する必要があります。

前段にCDNを噛ます

Lambdaは実行時間単位課金の比率が大きいので、静的なファイルへのリクエストはできるだけCDNから返してもらい、Lambdaへのリクエストを減らすことで節約になります。

AWSの場合はCloudFrontになるかと思います。2019年の記事とは異なり、S3を用意して静的ファイルだけそちらに分離するような小細工をするよりは、Lambda Function URLをそのままオリジンとして使ったほうがシンプルでいいんじゃないかな、と思います。

app.ts
<…前略>
  // Serve static files from /browser
  server.get(
    '*.*',
    express.static(browserDistFolder, {
      maxAge: '1y',
    })
  );
<…後略>

ただ、HTMLを返す部分では、Cache-Controlヘッダーが付与されていません。

HTMLを返すエンドポイントのレスポンスヘッダー

このままだと、ソースコードに変更を加えてデプロイしたときに、CDNキャッシュが更新されず、ユーザー視点で変更が反映されない懸念があるので、予めapp.tsCache-Control: no-cacheあたりを付けておくか、CloudFront側でキャッシュポリシーの作り分けをしておいたほうが良さそうです。

app.ts
<…前略>
  // All regular routes use the Angular engine
  server.get('*', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;

    commonEngine
      .render({
        bootstrap,
        documentFilePath: indexHtml,
        url: `${protocol}://${headers.host}${originalUrl}`,
        publicPath: browserDistFolder,
        providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
      })
      .then((html) => res.send(html)) <-- res.header()で設定すれば良さそう
      .catch((err) => next(err));
  });
<…後略>

Angular Materialは?

かつてのAngular Universalでは、基本的にAngular Materialとの併用が不可という認識だったのですが、v17からは普通に動作しちゃうようです。(すべての機能を調査したわけではないので、一部動かなかったらごめんなさい)

Angular MaterialもSSRされている

レスポンスを見ると普通にSSRされているので、CSRでしか利用できない関数を用いているようなコンポーネントやCDKの利用には注意が必要かもしれません。

この辺の情報、公式で出してくれればいいのにね。。。

ISRもできるらしい

https://www.rx-angular.io/docs/isr

RxAngularがSSR環境上でISRを実現するライブラリを公開していました。
サンプルコードを見た限りでは簡単に導入できそうなので、時間のあるときにこちらも試してみたいと思います。
ただ、ドキュメント上のサンプルコードがちょっと古い気がします。(@nguniversal/express-engineを使ったりしているので)

Lambdaで導入する場合は、インスタンスが大量に生えて大量に消えていく関係上、InMemoryだと旨味がないので、別途共有キャッシュ層が必要ですね。ドキュメントではRedisを使っていましたが、お金かけたくないので、DynamoDBでカスタムキャッシュハンドラーを作りたい。

SSR用のフレームワークの選定

https://x.com/laco2net/status/1714615961310138392?s=20

God of god(神)。

Angular CLIで作成するボイラープレートではExpressが使われていましたが、レイテンシを極限まで高めたい場合は、Fastifyでホスティングしてもいいかもしれません。
@h4ad/serverless-adapterでもFastify対応がありますので、爆速化とLambda実行時間削減によるコスト減が期待できます。

まとめ

これまではまともに使えるとは言い難かったSSR機能がAngular v17で改善されたことにより、SEOやレスポンスタイムが重要な、パブリックなアプリケーションへAngularを採用する余地が生まれました。
そういった分野はNext.jsやRemix等のReact系一択でしたが、とうとうAngularがReactと戦えるようになり、感涙を禁じえません。

今回はAWS Lambdaを使って検証してみましたが、動作原理は抽象化されていて非常に単純なので、その他のサーバーレスホスティングサービスでもそれほど苦労することなく動作させることが可能かと思います。

2024年、みなさんもAngular SSRで本番運用アプリケーション作ってみませんか?

Discussion