【Angularの新しいSSR環境】「Angular Universal」から「@angular/ssr」へのマイグレーションガイド


※ 当ページには【広告/PR】を含む場合があります。
2024/08/16
Angular Universalのサーバー(AWS Lambda)側で独自フォント(TTF/WOFF/WOFF2)がデコードできないときの対処法
蛸壺の技術ブログ|「Angular Universal」から「@angular/ssr」へのマイグレーション

当ブログは、これまで継続的に
「Angular Universal」(以降、Universal)によってCDNサーバーからプリレンダリングした記事を配給する、いわゆるSSG(Static Site Generation)で構築しております。

個人的にウェブサイトを運営していく上で、この「Universal」がもっとも重要な技術の一つであったわけですが、Angular v17からは正式にAngularの標準ライブラリへと昇格し、
「@angular/ssr」(以降、Angular/SSR)という名前に変わりました。

このことでAngular v17以降からは、Universalをそのまま使い続けることができなくなり、Angular/SSRへの完全移行が必要になります。

ここでは、Universal時代のSSG/SSRプロジェクトのレガシーコードをAngular/SSRへ引き上げたときの作業を中心に紹介していきます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

UniversalをAngular/SSRへ巻き上げる

少しだけ歴史を辿ると、そもそもAngular v17より前のバーションでは、UniversalはSSR/SSGへの対応はサイドメニュー的な扱いで、長らく有志のプログラマーの方々がほそぼそと開発していた時代が続きました。

どちらかというとサードパーティ提供のアドオンをインストールするような感覚で、環境をセットアップするだけでも非常に手間を取られ、クライアントサイドとサーバーサイドのそれぞれのコードを使い分け、それぞれにプログラムを別にビルドしないなどなど、何とも理解が難しいライブラリでした。

Angular v17以降、大きく進化したのが正式に加えられたビルダー・
「@angular-devkit/build-angular:application」の存在であり、これがなんとブラウザターゲット+サーバーターゲットの両方を同時にビルドしてくれるようになりました。

つまり、一からSSR/SSG対応のAngularアプリを開始しようとする場合、

            
            $ ng new --ssr
$ npm run build && npm run serve:ssr:[アプリケーション名]
        
だけで良くなってしまいました!

これがどれだけ楽チンかというと、かつてのUniversal時代ではとても分かりにくかった、ブラウザーパートとサーバーパートを住み分けした後に、分割ビルドを...

            
            #クライアントサイドのビルド
$ ng build --configuration production

#次段でサーバーサイドのビルド(SSRのみ/プレレンダリングは別)
$ ng run [アプリケーション名]:server:production
        
などとする必要があるなど、ビルドするだけでもプロジェクトの構成に込み入った調整が必要になっていました。

ともかく、AngularプロジェクトでもSSR/SSG対応の敷居がかなり低くなったことは喜ばしいことです。

v16からv17への移行作業

さて、本題の過去のUniversal時代のSSR/SSGレガシープロジェクトをここ最近のAngular/SSRに引き上げることを目指します。

NPMパッケージの名前を
@nguniversal@angular/ssrに変えただけは動きません。

@angular/ssrにする前に、ビルド環境をドラスティックに変えないとモダンなSSR対応にできないのです。

実際のところ、もう
ng new --ssrからプロジェクト初めて、コードを移植したほうが早いのでは...?と思えるくらいこれが面倒になります。

さほど大きいプロジェクトでなければ、最新のAngular v18で
ng new --ssrから始めたほうが圧倒的に楽です。

参考|Server-side rendering

手動でマイグレーションするポイントは大きく分けて以下の2つです。

            
            1. ビルドシステムの一新:
  Webpack(@angular-devkit/build-angular:browser)から
  esbuild(@angular-devkit/build-angular:application)へ移行

2. コンポーネントのスタンドアローン対応:
  全コンポーネントをNgModuleの削除などstandaloneへ移行
        
項目1は言わずもがな、ビルダーを変えないとAngular/SSRが使えないためです。

項目2もSSG/SSRをさせたいならば不可欠な変更で、以降で説明する"新方式"の
server.tsが暗黙的にstandaloneを利用させるコードになっているためです。

まずはこの辺の変更を踏まえて、以降のパートで作業の詳細を紹介していきます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

ビルドシステムを「@angular-devkit/build-angular:application」に移行する

v17からv18への移行はほぼ修正なしのシームレスで完了するため、過去のプロジェクトをいかに正常にv17まで引き上げられるかが争点になります。

まずやるべきは
package.jsonを手動で片っ端からangular17(~17.0.0)まで引き上げられるかを試します。

@angular系のライブラリのnpmバージョン、あとはtypescriptrxjszone.jsをv17相当のpeerdependenciesに対応させておきます。

            
            {
  //...中略
  "dependencies": {
    "@angular/animations": "~17.0.0",
    "@angular/common": "~17.0.0",
    "@angular/compiler": "~17.0.0",
    "@angular/core": "~17.0.0",
    "@angular/forms": "~17.0.0",
    "@angular/platform-browser": "~17.0.0",
    "@angular/platform-browser-dynamic": "~17.0.0",
    "@angular/platform-server": "~17.0.0",
    "@angular/router": "~17.0.0",
    "rxjs": "^7.4.0",
    "zone.js": "~0.14.0",
    //...
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "~17.0.0",
    "@angular/cli": "~17.0.0",
    "@angular/compiler-cli": "~17.0.0",
    "typescript": "~5.2.0",
    //...
  }
}
        
ほどよくpackage.jsonを手動アップグレードしたら、

            
            $ rm -rf yarn.lock node_modules
$ yarn install
        
で過去のプロジェクトのライブラリの掃除&パッケージを更新しておきます。

angular.jsonを手動でリニューアル

UniversalからAngular/SSRへの移行でもっとも肝になる作業が、
angular.jsonの設定変更です。

v17以降のangular-cliで新規プロジェクトを作成したらこういう修正作業も要らないのですが、既存の
angular.jsonを手動で設定を変更する場合を想定します。

必要最低限の修正点としてプロジェクトの
architect/buildarchitect/serveの設定を主にいじります。

            
            //...
"projects": {
  "プロジェクト名": {
    "projectType": "application",
    "schematics": {
      "@schematics/angular:component": {
        "style": "scss"
      }
    },
    "root": "",
    "sourceRoot": "src",
    "prefix": "app",
    "architect": {
      "build": {
        //👇ビルダーをbrowserからapplicationへ変える
        "builder": "@angular-devkit/build-angular:application",
        "options": {
          //👇Universal以前はdist/プロジェクト名/browserだったものから変える
          "outputPath": "dist/プロジェクト名",
          "index": "src/index.html",
          "browser": "src/main.ts",
          //👇polyfills.tsファイルの指定ではなく、個別ファイル指定となった
          "polyfills": ["zone.js"],
          "tsConfig": "tsconfig.app.json",
          "inlineStyleLanguage": "scss",
          "assets": [
            "src/favicon.ico",
            "src/assets",
          ],
          "styles": [
            "src/styles.scss"
          ],
          "scripts": [],
          //👇以下のserver/preprender/ssrの項目が新設
          "server": "src/main.server.ts",
          "prerender": {
              "discoverRoutes": true,
          },
          "ssr": {
            "entry": "server.ts"
          }
        },
        //👇configurationsからbuildOptimizer/vendorChunk/commonChunkが不要になった
        "configurations": {
          "production": {
            "optimization": true,
            "outputHashing": "none",
            "sourceMap": false,
            "namedChunks": true,
            "extractLicenses": false,
            "budgets": [
              {
                "type": "initial",
                "maximumWarning": "2mb",
                "maximumError": "5mb"
              },
              {
                "type": "anyComponentStyle",
                "maximumWarning": "15kb",
                "maximumError": "18kb"
              }
            ]
          },
          "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true,
              "namedChunks": true
          }
        }
      },
      "serve": {
        "builder": "@angular-devkit/build-angular:dev-server",
        "configurations": {
          "production": {
            //👇browserTargetからbuildTargetの名前に変更
            "buildTarget": "プロジェクト名:build:production"
          },
          "development": {
              //👇browserTargetからbuildTargetの名前に変更
              "buildTarget": "プロジェクト名:build:development"
          },
          "defaultConfiguration": "development"
        }
      },
//...
}
        
これでWebpackからesbuildに対応させることができました。

ちょっとだけ修正点を振り返ると、Universal時代に独立してあった
architect/server(@angular-devkit/build-angular:server)が不要になっています。

またUniversal付属だった
architect/serve-ssr(@nguniversal/builders:ssr-dev-server)も廃止され、全ては@angular-devkit/build-angular:applicationに統合されています。

ビルダーが廃統合された関係で、build設定項目から
polyfills.tsなどが要らなくなったり、serverなどの項目が新設されたり結構変更が多いです。

Typescriptのビルド設定も少し変更します。

tsconfig.app.jsonfilesをAngular/SSR用にリソースに変えておきます。

            
            {
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "outDir": "./out-tsc/app",
        "types": ["node"],
        "lib": [
          "esnext",
          "dom"
        ]
    },
    "files": [
        "src/main.ts",
        //👇サーバーサイドのアプリケーションもここに追加
        "src/main.server.ts",
        "server.ts"
    ],
    "include": [
        "src/**/*.d.ts"
    ]
}
        
で、Universal時代にあったtsconfig.server.jsonはもはや不要です。

ビルドシステムの変更への対応はこのへんでOKかと思います。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

プロジェクトコードをスタンドアローン化する

ある意味、手動でやると心が折れそうになるのが、全ての旧NgModuleコンポーネントをStandaloneコンポーネントへ変える作業です。

最近のAngularでは既に
*.module.tsを使ってライブラリを管理する必要がなく、スタンドアローンが前提になっています。

実際には、SSRやプリレンダリングをやる必要がないのならそのままNgModuleを使い続けることも可能ですが、SSR/SSGにするならば
*.module.tsなどがあるとスッキリとは移行できません。

まずは既存のAngularアプリケーションを「standalone」にマイグレーションしてみます。

そこで使えるのが、Angular謹製の「マイグレーションツール」になります。

早速これを使ってサクッとstandalone化対応してみます。

            
            $ ng generate @angular/core:standalone

? Choose the type of migration: (Use arrow keys)

❯ Convert all components, directives and pipes to standalone
  Remove unnecessary NgModule classes
  Bootstrap the application using standalone APIs
        
スタンドアローンへのマイグレーションツールは三本建てのメニューが一つになった対話式のプログラムであり、使用者は段階的にプロジェクトのコードを修正することができます。

まず、1つ目の
「Convert all components, directives and pipes to standalone」は、コンポーネント/ディレクティブ/パイプをstandaloneに変換するツールです。

            
            $ ng generate @angular/core:standalone

? Choose the type of migration: Convert all components, directives and pipes to standalone

? Which path in your project should be migrated? ./

    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
    See https://angular.dev/reference/migrations/standalone for more information.
UPDATE src/app/misc/components/thankyou/thankyou.component.ts (604 bytes)
UPDATE src/app/blog/blog-page/blog-page.component.ts (13522 bytes)
#...中略
UPDATE src/app/blog/blog.module.ts (2574 bytes)
UPDATE src/app/top-page/top-page.module.ts (2831 bytes)
UPDATE src/app/misc/notfound.module.ts (586 bytes)
        
コマンド実行時にマイグレーション対象のパスが指定できます。

今回は特に部分指定はせずデフォルト(
./)でプロジェクト全体を移行させます。

処理が終わると、各コンポーネントに
standalone: trueとそれぞれ必要なimportsが挿入されていることが分かります。

2つ目の
「Remove unnecessary NgModule classes」は不要になった NgModuleを削除するツールです。

            
            $ ng generate @angular/core:standalone

? Choose the type of migration: Remove unnecessary NgModule classes

? Which path in your project should be migrated? ./
    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
    See https://angular.dev/reference/migrations/standalone for more information.
Nothing to be done.
        
不要なものがない場合には何も起きません。

3つ目の
「Bootstrap the application using standalone APIs」はstandalone対応のbootstrapへ移行するツールです。

            
            $ ng generate @angular/core:standalone

? Choose the type of migration: Bootstrap the application using standalone APIs

? Which path in your project should be migrated? ./
    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
    See https://angular.dev/reference/migrations/standalone for more information.

DELETE src/app/app.module.ts
UPDATE src/main.ts (3273 bytes)
        
このツールの効能はちょっとしたもので、app.component.tsmain.tsが変更され、不要になったapp.module.tsは削除されます。

なお、このマイグレーションツールはスタンドアローン化への必要最低限の修正しかしてくれませんので、過去のレガシーから完全に移行させるにはもうちょっと手動で修正が必要です。

削除や変更の対象外となった不要なファイル等は手動で処分する必要があります。

例えば、Universal時代にサーバー用の設定で使っていた
app.server.module.tsも不要になります。

あとはなぜかapp.component.tsには
standaloneimportsが自動でつかないため手動にて追加しないといけないなど、少々残念なところもあります。

ともかくこのヘルパーツールで、大方のスタンドアローン化が自動で完了するため、作業の時短としては効果的です。

main.tsからmoduleの一切を削る

かつてのスタンドアローン前の時代には、
main.tsでプロジェクト内のngModuleは合流する最終地点でした。

Universal時代の典型的な
main.tsは以下のような形をしていたと思います。

            
            import { CommonModule } from '@angular/common';
import { enableProdMode, importProvidersFrom } from '@angular/core';
import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { withInterceptorsFromDi, provideHttpClient } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppComponent } from './app/app.component';
import { HogeModule } from './app/hoge.module';
import { PiyoModule } from './app/piyo.module';
import { AppRoutingModule } from './app/app-routing.module';

document.addEventListener('DOMContentLoaded', () => {
    bootstrapApplication(AppComponent, {
        providers: [
            importProvidersFrom(
                BrowserModule.withServerTransition({ appId: 'プロジェクト名' }), AppRoutingModule,
                CommonModule,
                FormsModule,
                ReactiveFormsModule,
                HogeModule,
                PiyoModule
            ),
            provideHttpClient(withInterceptorsFromDi()),
            provideAnimations()
        ]
    }).catch(err => console.error(err));
});
        
過去、「Angularは難しい」と言われ続けた当時の仕様を懐かしむことのできる割と難解なコードになっています。

これがスタンドアローン化対応後のv17になると、
main.tsもだいぶ様変わりしています。

            
            import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
    .catch((err) => console.error(err));
        
...どうでしょう、あまりにスッキリしすぎて逆に怖いくらいです。

これがスタンドアローンの力、恐ろしいほど簡素化しています。

app.component.ts

プロジェクトの
app.component.tsのレガシーコードも修正します。

必要最低限のコードは以下の通りです。

            
            import { Component } from '@angular/core';
//👇スタンドアローン化したルーターアウトレットを使う準備
import { RouterModule, RouterOutlet } from '@angular/router';

@Component({
    selector: 'app-root',
    template: `<router-outlet></router-outlet>`,
    //👇追加
    standalone: true,
    imports: [RouterOutlet, RouterModule]
})
export class AppComponent { }
        
これで最終的に<app-root>にレンダリング結果が投影されることになります。

app-routing.module.ts --> app.routes.ts

かつて
「app-routing.module.ts」のような名前でルートもNgModule化して利用する場合がありました。

スタンドアローン後ではルート定義だけでOKになり、名前も
「app.routes.ts」というものに置き換わっています。

簡単な例でいうと以下のような感じです。

            
            import { Routes } from '@angular/router';
import { HomeComponent } from './components/home.component';
import { PageNotFoundComponent } from './components/page-not-found.component';
import { ThankyouComponent } from './components/thankyou.component';

export const routes: Routes = [
    { path: 'home', component: HomeComponent },
    { path: 'error', component: PageNotFoundComponent },
    { path: 'confirmation', component: ThankyouComponent },
    { path: '', redirectTo: '/home', pathMatch: 'full' },
    { path: '**', redirectTo: '/error', pathMatch: 'full' }
];
        

app.config.tsとapp.config.server.ts

「app.config.ts」「app.config.server.ts」もv17以降に自動生成される正式なファイルとして取り扱いされるようになった新しいコードです。

app.config.tsは過去のapp.module.tsの内容を、app.config.server.tsapp.server.module.tsをそれぞれ引き継いています。

スタンドアローン化によってNgModuleの役目を終えたあとの産物で、移行後は必須のファイルになります。

コード内容としては、ほぼ定形構文なので、2つとも手動で以下のファイルを生成しておきましょう。

            
            import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideClientHydration(),
        provideAnimations(),
        provideHttpClient(),
    ]
};
        
そして、

            
            import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
    providers: [
        provideServerRendering()
    ]
};

export const config = mergeApplicationConfig(appConfig, serverConfig);
        
以上でザックリとですが旧式のAngularプロジェクトを手動でスタンドアローン化対応する作業は終わりです。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

UniversalをAngular/SSRへ移行する

先程まででプロジェクトのスタンドアローン化が完了したので、ここからいよいよUniversalをAngular/SSRへ切り替える作業をやっていきます。

なお、興味本位で、Angular v17の環境で、Universalを使ってみると、以下のような警告が出てきます。

            
            warning " > @nguniversal/common@16.1.3" has incorrect peer dependency "@angular/common@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/common@16.1.3" has incorrect peer dependency "@angular/core@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/express-engine@16.1.3" has incorrect peer dependency "@angular/common@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/express-engine@16.1.3" has incorrect peer dependency "@angular/core@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/builders@16.1.3" has incorrect peer dependency "@angular-devkit/build-angular@^16.0.0 || ^16.1.0-next.0".
        
何度も繰り返しますが、Angular v17以降では、Universalを使うことができないことが分かります。

で、ひとまずUniversalのパッケージを全て削除します。

            
            $ yarn remove @nguniversal/common @nguniversal/express-engine @nguniversal/builders
        
そして、Angular/SSR(v17)を手動でインストールしましょう。

            
            $ yarn add -D @angular/ssr@^17.0.0
        
Angularプロジェクト自体のフォルダ構造の構成はUniversal時代とおおよそ変わりませんが、サーバー関連のファイル構造が大きく異なります。

            
            my-app
|-- server.ts #サーバーアプリケーション
└── src
    |-- app
    |   └── app.config.server.ts #サーバーアプリケーション設定
    └── main.server.ts #サーバーのブートストラップ
        
先程も述べたように、Universalの時にはappフォルダ内に置いてapp.module.ts + app.server.module.tsだったものが、app.config.ts + app.config.server.tsに代わっているのが、Angular/SSRからの大きな特徴です。

特に、クライアントサイドで処理させたい実装は
app.config.tsに記述し、サーバーサイドで処理させたい実装はapp.config.server.tsに記述するなど、SSG/SSRの中核になる重要なファイルとなります。

app.config.tsのHttpClientモジュール対応

既に前述した
app.config.tsのコードには実は少し手を加えてあります。

ng new --ssrしたときに自動で生成されるapp.config.tsは以下のようなものです。

            
            import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideClientHydration(),
        provideAnimations()
    ]
};
        
このまま使うと、HttpClientが使えないため、以下のようなエラーが起こります。

            
            ERROR Error [NullInjectorError]: R3InjectorError(_ToppageModule)[_SubpageResolver -> _ProvisioningArticleService -> _HttpClient -> _HttpClient -> _HttpClient]: 
  NullInjectorError: No provider for _HttpClient!
    at NullInjector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:5627:27)
    at R3Injector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6070:33)
    at R3Injector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6070:33)
    at R3Injector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6070:33)
    at injectInjectorOnly (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:912:40)
    at Module.ɵɵinject (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:918:42)
    at Object.ProvisioningArticleService_Factory (/usr/src/app/workspace/src/app/service/provisioning-article.service.ts:16:40)
    at eval (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6192:43)
    at runInInjectorProfilerContext (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:868:9)
    at R3Injector.hydrate (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6191:17) {
  ngTempTokenPath: null,
  ngTokenPath: [
    '_SubpageResolver',
    '_ProvisioningArticleService',
    '_HttpClient',
    '_HttpClient',
    '_HttpClient'
  ]
}
        
スタンドアローン化より前であればHttpClientHttpClientModuleの一部でしたが、現在はprovideClientHydrationというサービスから提供するものになっています。

            
            import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';

//👇追加
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideClientHydration(),
        provideAnimations(),
        provideHttpClient(),
    ]
};
        

main.server.ts

サーバーのbootstrapを担当する
main.server.tsも随分と様変わりしました。

かつてのUniversal時代の実装は以下のようになっていました。

            
            import '@angular/platform-server/init';
import { enableProdMode } from '@angular/core';
import { environment } from './environments/environment';

if (environment.production) {
    enableProdMode();
    global['requestAnimationFrame'] = (callback: any) => {
        let lastTime = 0;
        const currTime = new Date().getTime();
        const timeToCall = Math.max(0, 16 - (currTime - lastTime));
        const id: any = setTimeout(() => {
            callback(currTime + timeToCall);
        }, timeToCall);
        lastTime = currTime + timeToCall;
        return id;
    };
    global['cancelAnimationFrame'] = (id) => {
        clearTimeout(id);
    };
} else {
    console.log('[Server/ExpressNodejs] main.server.ts is enabled Development mode.');
}

export { AppServerModule } from './app/app.server.module';
export { renderModule } from '@angular/platform-server';
        
このようにngModuleをエクスポートする形式でしたが、Angular/SSRのブートストラップでは、以下のようなスタンドアローン方式が推奨されています。

            
            import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = () => bootstrapApplication(AppComponent, config);

export default bootstrap;
        

server.ts

Universal時代からもそうでしたが、Angular/SSRでも
server.tsがサーバーで動くExpressアプリの本体になります。

Universal時代と比べてレンダリングエンジンを中心にガラリと仕様が違います。

ただし、Expressサイドのコードは変更なくほぼ流用できます。

            
            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';

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);
  
    server.get('/api/**', (req, res) => {
        res.status(404).send('data requests are not yet supported');
    });

    server.get(
        '*.*',
        express.static(browserDistFolder, {
        maxAge: '1y',
        }),
    );

    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: req.baseUrl}],
        })
        .then((html) => res.send(html))
        .catch((err) => next(err));
    });
    return server;
}

export * from './src/main.server';
        
ここでもng new --ssrしたあとで自動生成される標準のserver.tsからちょっと変えています。

標準ではローカルでExpressサーバー起動して動作確認したい際に使う
runメソッドも組み込まれています。

これは考え方の個人差もありますが、著者の使いがっての好みで、以下のような
local.tsという名前で、ビルド済みのコードからExpressアプリを引っ張ってきて、好きなタイミングでts-node起動する方式にします。

            
            import { app } from './dist/プロジェクト名/server/server.mjs';

function run(): void {
    const port = process.env.PORT || process.env.NG_DEV_PORT;

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

run();
        

ビルドして動作確認

以上のプロジェクト修正を経て、最後にビルドできるかを試します。

            
            $ ng build --configuration production
        
正常にビルドが完了すると、distフォルダにアプリの完成品が出力されていることが分かります。

            
            dist/
└── [プロジェクト名]
    ├── browser
    ├── prerendered-routes.json
    └── server
          ├── #...中略
          ├── main.server.mjs
          └── server.mjs
        
ローカルサーバーでテスト起動させる場合、ESMインポートを含んだコードのts-node直接起動が落ちてしまうため、tsxを使って、

            
            $ node --import tsx ./local.ts
        
とやるとローカルサーバーが起動します。

参考|tsxについて


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

その他細かい修正

ニッチな問題ですが、個人的な移行作業でちょっと引っ掛かった問題をメモしておきます。

Sassでのnode_modulesからのインポート

Angular v16以前では問題なかったのですが、esbuild対応にして、Sassのimport構文がコンパイルエラーを吐くようになってしまいました。

            
            @import '~katex/dist/katex.min.css';
        
少しルールで変わった?ようですので、angular.jsonのbuildタグにあるstylePreprocessorOptionsの項目を設け、以下のように外部リソースのパスを使えるように指定します。

            
            ///,
  "styles": [
    "src/styles.scss"
  ],
  "stylePreprocessorOptions": {
    "includePaths": [
      "../node_modules/katex/dist"
    ]
  },
///
        
これでAngular v17以降のプリプロセッサでもnode_modulesのパスが解決できるようになります。

            
            // @import '~katex/dist/katex.min.css';
//👇node_modulesまでの相対パスに変える
@import '../node_modules/katex/dist/katex.min.css';
        

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

まとめ

以上、なかなかボリューム感のある内容でしたが、コツコツと必要なファイル修正してもらうと過去に作ったUniversalプロジェクトでも問題なくAngular/SSRへ移行できることが分かります。

最近ではJS系フレームワークでもSSR対応の進んだNext.jsなどのほうが認知度が高いのは仕方ないですが、だいぶ使い勝手の向上したAngular/SSRで返り咲きのチャンスがあるか...と少し期待しながら温かい目で見守っていこうかと思います。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2022年版】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集