AWS Lambda で Angular アプリを Server Side Rendering してみる

[AWS Lambda Advent Calendar 2017](https://qiita.com/advent-calendar/2017/aws-lambda) の8日目です。

# 前書き

Server Side Rendering (SSR) は、いわゆる SPA (Single Page Application) において、ブラウザー上（クライアントサイド）で動的に生成される DOM と同等の内容を持つ HTML をサーバーサイドで出力するための仕組みです。

React、Vue 等のモダンなフロントエンドフレームワークに軒並み搭載されつつあるこの機能ですが、 Angular にもバージョン2以降 [Universal](https://github.com/angular/universal) という SSR の仕組みがあります。

この仕組みを Lambda の上で動かして、 API Gateway 経由で見れるようにしてみる記事です。

# SSR すると何がうれしいの？

[以下のようなメリットがあるとされています](https://angular.io/guide/universal#why-universal)。

- Web クローラーへの対応（SEO）
    - 検索エンジンのクローラーは HTML の内容を解釈してその内容をインデックスしますが、クライアントサイドで動的に変更された状態までは再現できません。そのため SSR に対応していない SPA では、コンテンツをクローラーに読み込ませるのが困難です。検索エンジンではないですが、 [OGP](http://ogp.me/) や [Twitter Card](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/abouts-cards) もクローラーで読み込んだ情報を元に表示していますね
- モバイル、低スペックデバイスでのパフォーマンス改善
    - いくつかのデバイスは JavaScript の実行パフォーマンスが非常に低かったり、そもそも実行できなかったりします。 SSR された HTML があれば、そのようなデバイスでもコンテンツを全く見られないという事態を避けられます
- 最初のページを素早く表示する
    - ある調査によると、[モバイルサイトの読み込みに3秒以上かかる場合、アクセスの53%が諦められてしまう](https://www.doubleclickbygoogle.com/articles/mobile-speed-matters/)のだそうです。 SPA は多くの機能をクライアントサイドで実装するため、初期ロードが長くなりがちですが、最初のビューが SSR されていればユーザーが何も見られない時間を減らせます

# どうやって Lambda で SSR するか

Angular Universal は各 Web サーバーフレームワーク用のミドルウェアとして実装されており、現時点では [Express](http://expressjs.com), [Hapi](https://hapijs.com/), [ASP.NET](https://www.asp.net/) 用のエンジンがリリースされています。

他方 Lambda には AWS が開発した、既存の Express アプリを Lambda 上で動かすための [aws-serverless-express](https://github.com/awslabs/aws-serverless-express) があります。

今回は [Angular Express Engine](https://github.com/angular/universal/blob/master/modules/express-engine/README.md) と [aws-serverless-express](https://github.com/awslabs/aws-serverless-express) を組み合わせて Lambda 上で Angular アプリを SSR してみます。

# やってみる

[公式の Universal チュートリアル](https://angular.io/guide/universal)をなぞりつつ、 Lambda 対応に必要な部分をフォローしていきます。

[Router](https://angular.io/guide/router) を使っていないと面白くないので、公式の Angular チュートリアルである [Tour of Heroes の完成段階](https://angular.io/tutorial/toh-pt6#final-code-review) をいじって SSR 対応にしてみましょう。

## チュートリアルのコードをまず動かす

コードを DL して展開、 `yarn` で依存物をインストールします。

ついでに `git init` して、 `.gitignore` も追加しておきましょう。

```shell-session
curl -LO https://angular.io/generated/zips/toh-pt6/toh-pt6.zip
unzip toh-pt6 -d toh-pt6-ssr-lambda
cd toh-pt6-ssr-lambda
yarn
```

`ng serve` で動くことを確認しておきます。

```shell-session
yarn run ng serve --open
```

## SSR に必要なものをインストール

```shell-session
yarn add @angular/platform-server @nguniversal/express-engine @nguniversal/module-map-ngfactory-loader express
yarn add --dev @types/express
```

## ルートモジュールを SSR 用に改変

```ts:src/app/app.module.ts
import { NgModule, Inject, PLATFORM_ID, APP_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

// ...

@NgModule({
  imports: [
    BrowserModule.withServerTransition({appId: 'toh-pt6-ssr-lambda'}),

// ...

export class AppModule {
  constructor(
    @Inject(PLATFORM_ID) private platformId: Object,
    @Inject(APP_ID) private appId: string,
  ) {
    const platform = isPlatformBrowser(platformId) ? 'browser' : 'server';

    console.log({platform, appId});
  }
}
```

## サーバー用ルートモジュールを追加

```
yarn run ng generate module app-server --flat true
```

```ts:src/app/app-server.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ServerModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    CommonModule,
    AppModule,
    ServerModule,
    ModuleMapLoaderModule,
  ],
  declarations: [],
  bootstrap: [AppComponent],
})
export class AppServerModule {}
```

## サーバー用ブートストラップローダーを追加

```ts:src/main.server.ts
export { AppServerModule } from './app/app.server.module';
```

## サーバーのコードを実装

```ts:server/index.ts
import 'zone.js/dist/zone-node';
import 'reflect-metadata';

import {enableProdMode} from '@angular/core';

import * as express from 'express';
import {join} from 'path';

// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();

// Express server
export const app = express();

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('../dist/serverApp/main.bundle');

// Express Engine
import {ngExpressEngine} from '@nguniversal/express-engine';
// Import module map for lazy loading
import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader';

app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,

  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ],
}));

app.set('view engine', 'html');
app.set('views', join(process.cwd(), 'dist', 'browserApp'));

// Server static files from /browser
app.get('*.*', express.static(join(process.cwd(), 'dist', 'browserApp')));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
  res.render(join(process.cwd(), 'dist', 'browserApp', 'index.html'), {req});
});
```

```ts:server/start.ts
import {app} from '.';

const PORT = process.env.PORT || 4000;

app.listen(PORT, () => {
  console.log(`Node server listening on http://localhost:${PORT}`);
});
```

*チュートリアルからの変更点*: あとで Lambda で使い回すのでこのコードでは `app.listen()` せずに単に `export` しておき、スタート用のファイルは別に用意します。ファイルの置き場所も若干変更しています。

## サーバー用のビルド設定を追加

```json:.angular-cli.json
// ...
  "apps": [
    {
      "platform": "browser",
      "root": "src",
      "outDir": "dist/browser",
// ...
    {
      "platform": "server",
      "root": "src",
      "outDir": "dist/server",
      "assets": [
        "assets",
        "favicon.ico"
      ],
      "index": "index.html",
      "main": "main.server.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.server.json",
      "testTsconfig": "tsconfig.spec.json",
      "prefix": "app",
      "styles": [
        "styles.css"
      ],
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }
```

```ts:src/tsconfig.server.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "commonjs",
    "types": []
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "app/app-server.module#AppServerModule"
  }
}
```

`.angular-cli.json` にはクライアント用のビルド設定しか書かれていないので、ここにサーバーサイド用アプリのビルド設定を追加します。 `outDir` が被らないように変えておきます。

`ng build` では「サーバーサイド用の Angular アプリのビルド」までは面倒を見てくれますが、サーバー自体のコードのビルドは自力でやる必要があるので、もろもろ追加します。

```shell-session
yarn add --dev awesome-typescript-loader webpack copy-webpack-plugin concurrently
```

```js:server/webpack.config.js
const {join} = require('path');
const {ContextReplacementPlugin} = require('webpack');
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: {
    server: join(__dirname, 'index.ts'),
    start: join(__dirname, 'start.ts'),
  },

  resolve: { extensions: ['.js', '.ts'] },
  target: 'node',
  externals: [/(node_modules|main\..*\.js)/],
  output: {
    path: join(__dirname, '..', 'dist', 'server'),
    filename: '[name].js'
  },
  module: {
    rules: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }]
  },
  plugins: [
    new CopyWebpackPlugin([
      {from: "dist/browserApp/**/*"},
      {from: "dist/serverApp/**/*"},
    ]),

    // Temporary Fix for issue: https://github.com/angular/angular/issues/11580
    // for 'WARNING Critical dependency: the request of a dependency is an expression'
    new ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      join(__dirname, '..', 'src'), // location of your src
      {} // a map of your routes
    ),
    new ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      join(__dirname, '..', 'src'),
      {}
    )
  ]
};
```

サーバーの実装上の都合で `(クライアント用の Angular ビルド + サーバー用の Angular ビルド) → サーバーのビルド` という手順を踏む必要があるので、一連のビルド用スクリプトを追加します。

```json:package.json
    "build:prod": "concurrently --names 'browser,server' 'ng build --prod --progress false --base-href /prod/ --app 0' 'ng build --prod --progress false --base-href /prod/ --app 1 --output-hashing false'",
    "prebuild:server": "npm run build:prod",
    "build:server": "webpack --config server/webpack.config.js",
    "start:server": "node dist/server/start.js"
```

`--base-href` を指定しているのは、後で API Gateway の仕様との整合性をとるためです。

## ローカルでサーバーを動かしてみる

```shell-session
yarn run build:server
yarn run start:server
```

http://localhost:4000/prod/detail/14 あたりにアクセスして、 SSR されているか確認してみます。

<img width="1057" alt="Tour_of_Heroes.png" src="https://qiita-image-store.s3.amazonaws.com/0/78376/1d393366-4fd6-a150-2845-4ece30732cd7.png">

最初のリクエストに対するレスポンスで、 Angular が生成した HTML が返ってきていることがわかります。[チュートリアル第1章で pipe を使って大文字にした](https://angular.io/tutorial/toh-pt1#format-with-the-uppercasepipe)ところもちゃんと再現されていますね。

普通に `ng serve` した場合はこんな感じのはずです。

<img width="1065" alt="Tour_of_Heroes.png" src="https://qiita-image-store.s3.amazonaws.com/0/78376/70dd2020-557a-8dae-9625-f3cf9fc6d1e2.png">

## Lambda にデプロイする

おなじみの [Serverless Framework](https://serverless.com/framework/) を使います。いつもありがとうございます。

ここで `aws-serverless-express` も追加しましょう。

```shell-session
yarn add aws-serverless-express
yarn add --dev @types/aws-serverless-express serverless serverless-webpack
```

Lambda 用のエントリーポイントを追加します。

```ts:lambda/index.ts

import {createServer, proxy} from 'aws-serverless-express';

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

export default (event, context) => proxy(createServer(app), event, context);
```

Lambda (serverless-webpack) 用のビルド設定を追加します。さっきのやつとほぼ同じですが、 `libraryTarget: "commonjs"` がないと動かないようです。

```js:lambda/webpack.config.js
const {join} = require('path');
const {ContextReplacementPlugin} = require('webpack');
const CopyWebpackPlugin = require("copy-webpack-plugin");
const slsw = require('serverless-webpack');

module.exports = {
  entry: slsw.lib.entries,
  resolve: { extensions: ['.js', '.ts'] },
  target: 'node',
  externals: [/(node_modules|main\..*\.js)/],
  output: {
    libraryTarget: "commonjs",
    path: join(__dirname, '..', 'dist', 'lambda'),
    filename: '[name].js'
  },
  module: {
    rules: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }]
  },
  plugins: [
    new CopyWebpackPlugin([
      {from: "dist/browserApp/**/*"},
      {from: "dist/serverApp/**/*"},
    ]),

    // Temporary Fix for issue: https://github.com/angular/angular/issues/11580
    // for 'WARNING Critical dependency: the request of a dependency is an expression'
    new ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      join(__dirname, '..', 'src'), // location of your src
      {} // a map of your routes
    ),
    new ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      join(__dirname, '..', 'src'),
      {}
    )
  ]
};
```

Serverless の設定ファイルを追加します。あらゆるパスへの `GET` を Lambda にルーティングするように設定します。

```yaml:serverless.yml
service: toh-pt6-ssr-lambda
provider:
  name: aws
  runtime: nodejs6.10
  region: ${env:AWS_REGION}
  memorySize: 128
plugins:
- serverless-webpack
custom:
  webpack: lambda/webpack.config.js
  webpackIncludeModules: true
functions:
  main:
    handler: lambda/index.default
    events:
    - http:
        path: /
        method: get
    - http:
        path: "{proxy+}"
        method: get
```

Serverless をインストールした状態でビルドしようとすると以下のようなエラーが出るので、とりあえず `tsconfig.json` を変更して回避しておきます。

```
ERROR in [at-loader] ./node_modules/@types/graphql/subscription/subscribe.d.ts:17:4
    TS2304: Cannot find name 'AsyncIterator'.

ERROR in [at-loader] ./node_modules/@types/graphql/subscription/subscribe.d.ts:29:4
    TS2304: Cannot find name 'AsyncIterable'.
```

```json:tsconfig.json
    "lib": [
      "es2017",
      "dom",
      "esnext.asynciterable"
    ]
```

Lambda 用のデプロイスクリプトを追加します。

```json:package.json
    "predeploy": "npm run build:prod",
    "deploy": "serverless deploy"
```

で、ようやく Lambda のデプロイです。

```shell-session
yarn run deploy
```

うまくいけば Serverless によって各種 AWS リソースが作られ、 API Gateway のエンドポイントが出力されるはずです。

## API Gateway にアクセスして SSR されているか見てみる

先程と同様に確認してみます。

<img width="1114" alt="Tour_of_Heroes.png" src="https://qiita-image-store.s3.amazonaws.com/0/78376/85d15648-d3ce-1c1d-f2e3-20d37e926b53.png">

よさげ。

# 今後の課題

Lambda + API Gateway で SSR することができました。しかし、いろいろと課題はまだありそうです。

## HTTP ステータスコード問題

現状のコードでは固定的に `200` を返しているため、ありえないパスにリクエストが来てもクローラー的には `OK` と解釈されてしまいます。本来なら `404` などを適切に返すべきです。

## HTML 以外をどうやって動的に返すか

一般的なサイトでは `sitemap.xml` など動的な内容かつ HTML ではないものも返す必要があります。これを Angular でできるかどうか、調べる必要がありそうです。

## Express なくせるんじゃね？

今回は `Angular -> Universal + Express Engine -> Express -> aws-serverless-express -> Lambda (API Gateway Proxy Integration)` という繋ぎ方をしました。

これを `Angular -> Universal + [何か] -> Lambda (API Gateway Proxy Integration)` にできたら構成要素が減らせていい感じですよね。 

[renderModuleFactory](https://angular.io/api/platform-server/renderModuleFactory) などを自力で叩く実装ができれば、これが実現できるのかもしれません。

# 今回作ったコード

- [y13i/toh-pt6-ssr-lambda](https://github.com/y13i/toh-pt6-ssr-lambda)

# 参考

- [Angular - Angular Universal: server-side rendering](https://angular.io/guide/universal)
- [Angular2のServer Side Renderingに触れてみる - Qiita](https://qiita.com/Quramy/items/8389e14fa480f9edeaf7)