Makina Blog

Le blog Makina-corpus

Mettre en place Angular Universal avec Angular 6 et 7


Le fonctionnement d'Angular Universal expliqué. Toutes les étapes de mise en place détaillées. Les pièges à éviter.

Il y a un peu plus d'un an, nous vous expliquions comment mettre en place Angular Universal avec Angular 4 . Les choses ont beaucoup changé, avec les progrès de l'intégration d'Universal dans Angular 5 et 6, et avec le client nouvelle génération (Angular Cli 6+). Cet article vous propose une mise à jour complète, et va un peu plus en profondeur dans les explications.

Un petit point sur les versions

Cet article ne fonctionnera qu'avec des versions récentes d'Angular

  • Angular >= 6
  • Angular Cli >= 6

Angular Universal, qu'est ce que c'est ?

Angular Universal c'est l'ensemble des briques techniques qui permettent de générer côté serveur le rendu HTML d'une Single Page Application Angular. Ça améliore la qualité du rendu de la première page affichée par un visiteur. Surtout, c'est indispensable pour le référencement, l'archivage et le partage sur les réseaux sociaux : les moteurs, aujourd'hui, se basent essentiellement sur le html renvoyé par le serveur, et ne sont pas optimisés pour traiter au mieux le dom généré côté client. (Voir pour cela notre article SEO : indexer une application Javascript)

Aperçu du fonctionnement du server side rendering Angular

Le rendu "serveur" d'une application angular "navigateur" met en oeuvre trois composantes.

Un serveur http nodejs reçoit des requêtes et renvoit des réponses http. On utilise en général Express.

Ce serveur http utilise un moteur de rendu qui, à partir de l'url de la requête, utilise les briques de rendu Angular pour générer un contenu html. C'est @angular/platform-server qui s'occupe de cela.

Ce moteur de rendu met en oeuvre une application métier serveur, qui est une application Angular quasiment identique à une application angular "navigateur". Il s'agit en général d'un module "serveur" spécifique AppServerModule qui initialise (bootstrap) le AppComponent d'une application "navigateur" dans un environnement particulier. On l'intègre en général avec le code de l'application client, sous le fichier main.server.ts.

Voici un schéma de ce fonctionnement :

Serveur Http (Express)
  -> Moteur de rendu (platform-server) 
      -> Application Angular "serveur" (main.server.ts)
          -> Module Angular "serveur" (app.server.module.ts)
              -> Composant Angular (app.component.ts)

Nous allons configurer tout cela en partant du principe qu'on a déjà une application client. Nous allons commencer par l'application serveur Angular, puis nous nous occuperons ensuite du serveur http qui utilise le moteur de rendu.

Configuration de l'application serveur Angular

Avant tout, quelques légères modificiations doivent être apportées à l'application client existante.

Modifications du module AppModule

Au niveau du module AppModule, vous allez simplement changer la façon d'importer BrowserModule :

// src/app/app.module.ts
@NgModule({
  imports: [
    BrowserModule.withServerTransition({ appId: 'super-app-universal' }),

On s'assure ainsi que l'application, dans le navigateur, pourra s'initialiser à partir d'un rendu serveur.

Création du module spécifique serveur AppServerModule

Vous allez créer un nouveau module spécifique pour le mode serveur : AppServerModule, dans src/app/app.server.module.ts :

// src/app/app.server.module.ts
import { ServerModule } from '@angular/platform-server';
import { NgModule } from '@angular/core';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
  declarations: [],
  imports: [
    ServerModule,
    AppModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppServerModule { }

Ici on importe le module serveur de base (ServerModule) et le module applicatif (AppModule) pour qu'ils partagent le même appId.

Enfin, vous allez créer un point d'entrée src/main.server.ts qui contiendra uniquement un export du module serveur :

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

Création du module spécifique client AppBrowserModule

Nous vous conseillons de créer un module spécifique client qui contiendra les briques qui n'ont pas besoin d'être présentes dans le module serveur.

Vous aurez un module AppBrowserModule qui dépendra de AppModule. Créez-le dans un fichier src/app/app.browser.module.ts :

// src/app/app.browser.module.ts
@NgModule({
    imports: [
        AppModule,
        HttpClientXsrfModule.withOptions({
            cookieName: 'csrftoken',
            headerName: 'X-CSRFToken'
 }),
    ],
    bootstrap: [AppComponent]
})
export class AppBrowserModule {
}

Ici par exemple, nous avons importé le module de gestion du CSRF dans le module client seulement : il n'est pas utile dans le module serveur, car jamais des requêtes POST/PUT/DELETE ne seront faites depuis celui-ci.

Pour plus de consistance, vous renommerez le module main.ts en main.browser.ts. Pensez à corriger celui-ci pour qu'il importe le module AppBrowserModule :

// main.browser.ts
// ...
import {AppBrowserModule} from './app/app.browser.module';
// ...

Voilà pour ce qui concerne l'application elle-même. Vous noterez que le AppComponent intitialisé est le même pour les deux modules. C'est le but ! Une fois que vos modules sont configurés, la partie fonctionnelle (les composants et services) se comportera de manière quasi identique côté serveur et côté client (c'est ce qu'on appelle l'isomorphisme.)

Vous aurez donc les fichiers suivants :

├── app
│   ├── app.browser.module.ts
│   ├── app.component.ts
│   ├── app.module.ts
│   ├── app-routing.ts  // AppRoutingModule facultatif. importe et exporte RouterModule
│   ├── app.server.module.ts
├── main.browser.ts
├── main.server.ts

Configuration du cli (angular.json)

Vous avez maintenant deux applications : une application client (app.browser) et une application serveur (app.server). Pour pouvoir les compiler, il vous faut mettre à jour la configuration du cli angular.json.

Configuration du build client

Le build client ne change pas à trois choses près :

  • Vous pouvez renommer le projet pour que sa fonction de browser soit explicite (par exemple browserApp).
  • Vous aurez besoin de séparer votre build en deux dossiers, un pour le client et un pour le serveur : renommez donc votre dossier output en dist/browser.
  • Mettez à jour le nom du main : main.browser.ts
// angular.json
"projects": {
  "browserApp": {
    "root": "",
    "sourceRoot": "src",
    "projectType": "application",
    "prefix": "app",
    "schematics": {},
    "architect": {
      "build": {
        "builder": "@angular-devkit/build-angular:browser",
        "options": {
          "outputPath": "dist/browser",
          "index": "src/index.html",
          "main": "src/main.browser.ts",
          "tsConfig": "src/tscfonfig.browser.json",
// (...)
"defaultProject": "browserApp"

Dans la configuration typescript du client, pensez à exclure les fichiers concernant le serveur.

// src/tsconfig.browser.json
{
  "extends": "../tsconfig.json",
  "exclude": [
    "**/*.spec.ts",
    "**/app.server.module.ts",
    "**/main.server.ts",
    "**/test.ts",
    "**/test/*.ts"
 ]
}

Configuration du build serveur

L'application serveur doit être compilée pour pouvoir être exécutée par le moteur de rendu. Vous devez ajouter un projet pour la compilation de l'app serveur.

"projects": {
  // "browserApp": (...)
  "serverApp": {
    "root": "",
    "sourceRoot": "src",
    "projectType": "application",
    "prefix": "app",
    "architect": {
      "build": {
        "builder": "@angular-devkit/build-angular:server",
        "options": {
          "outputPath": "dist/server",
          "main": "src/main.server.ts",
          "tsConfig": "src/tsconfig.server.json"
        },
        "configurations": {
          "production": {
            "fileReplacements": [
              {
                "replace": "src/environments/environment.ts",
                "with": "src/environments/environment.prod.ts"
              }
            ],
            "optimization": true,
            "outputHashing": "none"
          }
        }
      }
    }
  }

À noter :

  • Le builder build-angular:server va construire un bundle du module serveur sans index.html.
  • Le dossier du module serveur compilé sera dist/server.
  • Il est essentiel de désactiver le outputHashing car le nom du fichier main.bundle doit être prédictible (cf plus loin le src/tsconfig.server.json).

La configuration du build typescript du serveur devra préciser, par l'option entryModule le point d'entrée qui pourra être importé par le serveur http, en l'occurence le module serveur :

// src/tsconfig.server.json
{
  "extends": "../tsconfig.json",
  "exclude": [
    "**/*.spec.ts",
    "**/test.ts",
    "**/test/*.ts",
    "**/main.browser.ts",
    "**/app/app.browser.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "app/app.server.module#AppServerModule"
  }
}

Tâches de build du client et du serveur (package.json)

Ajoutez à votre package.json des tâches pour builder les applications server et client, par exemple :

"build-browser-app": "rm -Rf dist/browser && ng build browserApp --configuration production",
"build-server-app": "rm -Rf dist/server && ng build serverApp --configuration production",

Le serveur http de rendu

Le serveur http est un serveur Express qui utilise @angular/platform-server pour faire le rendu de l'application serveur compilée.

Il sera défini par un fichier TypeScript qui sera compilé par webpack en un Javascript dist/server.js lancé par NodeJS, server.ts.

Vous aurez besoin de ces dépendances :

npm install --save @angular/platform-server express

Si votre application comporte des routes lazy loaded, il vous faudra également installer ceci :

npm install --save @nguniversal/module-map-ngfactory-loader

Voici un exemple de serveur :

// src/server.ts
import { enableProdMode } from '@angular/core';
import { renderModuleFactory } from '@angular/platform-server';
import * as express from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import 'reflect-metadata';
import 'zone.js/dist/zone-node';

const { provideModuleMap } = require('@nguniversal/module-map-ngfactory-loader');
// 
const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('../dist/server/main');

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

const mode = (process.argv[2] === '--dev') ? 'development' : 'production';

const app = express();
if (mode === 'production') {
    enableProdMode();
    app.disable('x-powered-by');
}

const template = readFileSync(join(__dirname, '..', 'dist', 'browser', 'index.html')).toString();

app.engine('html', (_, options, callback) => {
    const opts = {
        document: template,
        url: options.req.url,
        extraProviders: [
            provideModuleMap(LAZY_MODULE_MAP),
        ]
    };
    renderModuleFactory(AppServerModuleNgFactory, opts)
        .then(html => callback(null, html));
});

app.set('view engine', 'html');
app.set('views', 'src');

# si vous avez des fichiers statiques
# app.get('*.*', express.static(join(__dirname, '..', 'dist', 'server')));

app.get('*', (req, res) => {
    res.render('index', { req });
});

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

Plusieurs détails sont notables dans ce script :

  • renderModuleFactory est la fonction qui génère un html sous forme de chaîne de caractères à partir d'une url, d'une "template" et d'une app serveur compilée.
  • comme template, on utilise l'index.html de l'application client. Oui, client ! le html renvoyé par le serveur est une version prérendue de l'application serveur, mais renvoit l'application client elle-même : un document html avec les liens vers les javascripts client (hashés, eux) et le slot de l'application client (<app></app>).
  • ../dist/server/main.js est le bundle de l'application serveur compilée en utilisant angular-cli (ngc). Elle expose le module factory (AppServerModuleNgFactory) qui contient les règles métier de rendu de l'app, et LAZY_MODULE_MAP, les modules lazy loaded.

Webpack va inclure dans ce javascript le serveur compilé en js, l'ensemble du code de l'application angular et de ses dépendances à l'exception des "node externals".

Build du serveur

Pour builder le serveur vous avez besoin de quelques dépendances supplémentaires :

npm install ts-loader webpack-node-externals webpack-cli --save-dev

À noter : webpack (4) est inclus par dépendance de @angular-devkit/build-angular, gardez vous d'installer une autre version à la racine car vous auriez des conflits.

Et il vous faut une petite configuration Webpack pour faire le bundling pour Node, webpack.server.config.js :

// webpack.server.config.js
var nodeExternals = require('webpack-node-externals');
var path = require('path');
var webpack = require('webpack');


module.exports = {
    entry: {
        server: "./src/server.ts"
    },
    resolve: {
        extensions: [".ts", ".js"]
    },
    target: "node",
    // this makes sure we include node_modules and other 3rd party libraries
    externals: [nodeExternals({
        whitelist: [
            /^ngx-bootstrap/,
            /^angulartics2/,
            /^mydaterangepicker/
        ]
    }),
        /(main\..*\.js)/],
    node: {
        __dirname: false,
        __filename: false
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: "[name].js"
    },
    module: {
        rules: [
            {
                test: /\.ts$/,
                loader: "ts-loader"
            }
        ]
    }
};

Notez ce qu'on met dans les nodeExternals. Cela permet d'exclure du bundling Node les dépendances du projet. En effet, la plupart des packages Angular sont publiés au format ES6, mais Node utilise CommonJS, et donc si on les inclut, on se retrouve avec des erreurs de syntaxe (typiquement: Unexpected token export ou Unexpected token import).

Et pour finir, ajoutez 2 scripts NPM dans votre package.json :

"start-server": "node dist/server.js",
"build-server": "webpack --mode production --config webpack.server.config.js --progress --colors",

Pour compiler le serveur, il faut avoir compilé l'app serveur et l'app client au préalable !

Pour démarrer votre app sous Universal, il suffit de lancer :

npm run start-server

Notes sur l'exploitation du serveur node en production

  • Le serveur node sera géré par un gestionnaire de processus (par exemple, supervisor)
  • Les packages node externals doivent être en présents sur le serveur de production. Vous ferez donc un npm install lors de vos déploiements. Les package node externals doivent être dans les "dependencies".

Configuration du serveur (Nginx)

Si votre projet de Single Page Application est déjà en production ou en qualification, vous devriez avoir un serveur qui la sert de manière statique. Il vous faut maintenant servir, pour les robots, la version générée dynamiquement.

notez que dans cet exemple on applique le mode 'universal' et le pré-rendu uniquement pour les robots. Vous pourriez vouloir l'utiliser tout le temps (pour un rendu de page initial plus rapide par exemple). Il faut par contre faire attention à la charge que ce mode serveur implique, faire tourner angular sur le serveur pour rendfre une page ce n'est pas la même chose que le faire tourner dans le navigateur du client, ici la consommation de RAM et de CPU est pour vous.

Vous aurez besoin d'un upstream et d'une location supplémentaire permettant de passer les requêtes au serveur node…

upstream universalnode { server localhost:4000; } 
location @prerender {
   proxy_pass http://universalnode
}

…et de configurer le fait d'utiliser cette location si vous détectez que la requête est un robot. Ci-dessous, nous donnons la valeur 1 à la variable prerender si l'on détecte que l'on a affaire à un robot suivant le user agent ou la query string (attention à tenir cette configuration à jour !)

map $http_user_agent $prerender_via_agent {
  default 0;
  "~(? i)googlebot|yahoo|bingbot|baiduspider|yandex|yeti|yodaobot|gigabot|ia_archiver|facebookexternalhit|twitterbot|developers\.google\.com" 1;
}

map $args $prerender_via_uri {
   default 0;
  "~(_escaped_fragment_|prerender)=1" 1;
}

map "$prerender_via_agent_prerender_via_uri" "$prerender" {
  default 0;
  "~1" 1;
}

Et nous mettons à jour la location des ressources statiques pour préférer la location @prerender dans ce cas (notez l'usage a minima du if, le if est une instruction dangereuse en Nginx, mais ici son usage est valide, on termine le bloc if sur un 'faux' code d'erreur, et c'est une des règles à bien respecter avec le if, avoir un return définitif à l'intérieur). L'idée est d'utiliser ce code d'erreur 430 en interne pour faire le reverse proxy vers Nodejs. Il y a certainement d'autres façons d'obtenir le même effet.

error_page 430 = @prerender;
location / {
  if ( $prerender = 1 ) {
      return 430;
  }
  try_files $uri$args $uri$args/ /index.html;  // 
}

Il est enfin conseillé de mettre en cache les pages prérendues (par exemple avec un proxy_cache nginx). Comme expliqué plus haut la charge de CPU est pour vous à chaque rendu de page en mode universal, il faut donc éviter de le faire pour chaque requête, donc si vous pouvez gérer une durée de cache pour la page rendue ce sera une très bonne chose. Je vous laisse explorer les solutions existantes dans Nginx, ou via d'autres outils (type Varnish).

Conseils pour le développement d'une application isomorphe

Si vous prévoyez de mettre en place Angular Universal, il vaut mieux le faire dès le début du projet, et s'assurer régulièrement de son fonctionnement..

Les pièges à éviter

Universal n'est pas capable d'utiliser les objets globaux existants au runtime dans votre navigateur comme document, window, localStorage, etc.

De la même façon, le DOM doit être uniquement manipulé via les template Angular, et pas de façon externe (jQuery, vanilla javascript…), ce qui force à respecter les bonnes pratiques de développement Angular. Attention donc si votre équipe n'est pas un peu expérimentée sur la solution.

Si votre application se sert de window ou d'autres objets du navigateur (ou, plus probablement, si elle appelle des librairies qui s'en servent), il suffit de tester au préalable si on est dans un contexte serveur ou navigateur. Angular fournit les méthodes isPlatformBrowser et isPlatformServer pour cela :

import { Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

class MyService {
  constructor(@Inject(PLATFORM_ID) private platformId: Object) {}

  store(key: string, value: string) {
      if(isPlatformBrowser(this.platformId)) {
          localStorage.setItem(key, value);
      }
  }
}

Et ainsi le code est compatible avec Angular Universal.

Si vous utilisez les animations, vous devrez faire attention à les activer côté client seulement. Pour cela, le module d'animations BrowserAnimationsModule sera importé dans le module AppBrowserModule seulement, tandis que dans le module AppServerModule, vous importerez le module NoopAnimationsModule qui mock le module d'animations.

// app.server.js
@NgModule({
  imports: [
    ServerModule,
 AppModule,
 NoopAnimationsModule
 ],
 bootstrap: [AppComponent]
})
export class AppServerModule { }

Votre application fait certainement des requêtes HTTP. Cela est bien géré par Universal : il attend que toutes les souscriptions à des requêtes HTTP soient exécutées avant de renvoyer la réponse au client. La seule modification que vous aurez à apporter côté serveur est que les urls de vos requêtes HTTP doivent être absolues.

Notez également qu'il vous faudra réenvisager côté serveur toutes les routines qui font des opérations régulières ou temporisées, avec des opérateurs de type repeatWhen, delay, etc. Elles peuvent ralentir voire bloquer le renvoi de la réponse !

Le serveur ne gèrera pas les cookies. La bonne nouvelle c'est que vous n'en aurez pas expressément besoin. En effet, si vous utilisez Angular pour le SEO vous ne souhaitez pas de toute façon faire le rendu des pages connectées. Si vous utilisez Universal pour tous les utilisateurs, la connection se fera lors du rendu côté client. Pensez juste à activer les services utilisant les cookies, par exemple la stratégie XSRFStrategy, côté client seulement (donc dans AppBrowserModule).

Optimisations pour le SEO et les réseaux sociaux

Vous ne vous êtes peut-être pas encore posé la question de gérer le titre ou les meta de la balise HEAD jusqu'ici, mais cela devient sûrement plus utile maintenant qu'on fait du rendu côté serveur (à destination des moteurs de recherche et des réseaux sociaux). Cela est très simple à faire, Angular fournit des services pour cela et ils sont parfaitement opérants sous Universal :

import { Meta, Title } from '@angular/platform-browser';
...

 constructor(
   private meta: Meta,
   private title: Title,
 ) {}

 ngOnInit() {
   this.title.setTitle('Ma page');
   this.meta.updateTag({
     name: 'description',
     content: '...et ma description'
   });
 }

Ici on n'a mis que la description, mais le service meta nous permettrait de la même manière de renseigner les attributs de la Twitter Card, les attribut OpenGraph, etc.

Vous serez probablement aussi bien inspirés de mettre à disposition un robots.txt et un sitemap.xml.gz à la racine de votre site.

Le robots.txt peut-être mis directement dans src/, il suffit de le mentionner dans les assets du angular.json pour qu'il se retrouve dans le build.

Vous pouvez faire la même chose pour le sitemap.xml(.gz), sauf que souvent son contenu est dynamique, donc c'est plus embêtant. Si vous avez la chance d'utiliser un CMS en backend (comme Plone ou Django dans notre cas), vous avez un endpoint qui vous renvoie ce fichier, il suffit alors de mettre cette URL dans le robots.txt.

Codes d'erreur HTTP

A priori, vous n'enverrez que des statuts HTTP 200 depuis votre serveur node Universal. En effet, votre code front-end est initialement conçu, en cas d'absence de contenu, ou en cas d'erreur, pour afficher simplement un contenu spécifique. Si vous avez besoin de répondre des 404, 410, 500, etc. aux requêtes provenant des bots, vous pouvez injecter votre objet Response Express dans votre code Angular, avec l'attribut extraProviders, de cette façon (on injecte aussi l'objet request, ici) :

// src/server.ts
// remplacer :
app.engine('html', (_, options, callback) => {
  const opts: PlatformOptions = {
    document: template,
    url: options.req.url,
    extraProviders: [
      <ValueProvider>{
          provide: 'REQUEST',
          useValue: options.req
      },
      <ValueProvider>{
          provide: 'RESPONSE',
          useValue: options.req.res,
      },
      provideModuleMap(LAZY_MODULE_MAP),
     ]
  };
  renderModuleFactory(AppServerModuleNgFactory, opts)
    .then(html => {
        return callback(null, html)
    });
});

Dans le AppBrowserModule, vous fournirez (provide) des valeurs par défaut pour ces clés :

// src/app/app.browser.module.ts

@NgModule({
// ...
providers: [
  { provide: 'RESPONSE', useValue: {}},
  { provide: 'REQUEST', useValue: {}},

Dans le composant qui affiche la page 404, vous pourrez faire ceci :

constructor(@Inject(PLATFORM_ID) platformId: Object,
            @Inject('RESPONSE') response: Object) {}
ngOnInit() {
  if (!isPlatformBrowser(platformId)) {
    this.response.statusCode = 404
 }
}

Si votre application est un site internet, implémenter au moins les réponses 404 est très fortement conseillé pour le référencement

À vous maintenant

L'avez-vous mis en place ? Avez-vous des retours d'expérience à partager ? Rencontrez-vous des problèmes ?

CONTACTEZ-NOUS si vous avez besoin d'aide !

Validez son bon fonctionnement en nous demandant un AUDIT TECHNIQUE DE RÉFÉRENCEMENT !

Actualités en lien

Image
Capture d'une partie de carte montrant un réseau de voies sur un fond de carte sombre. Au centre, une popup affiche les information de l'un des tronçons du réseau.
28/02/2024

Géné­rer un fichier PMTiles avec Tippe­ca­noe

Exemple de géné­ra­tion et d’af­fi­chage d’un jeu de tuiles vecto­rielles en PMTiles à partir de données publiques.

Voir l'article
Image
Read The Docs
01/02/2024

Publier une documentation VitePress sur Read The Docs

À l'origine, le site de documentation Read The Docs n'acceptait que les documentations Sphinx ou MKDocs. Depuis peu, le site laisse les mains libres pour builder sa documentation avec l'outil de son choix. Voici un exemple avec VitePress.

Voir l'article
Image
SIG PostGIS
17/01/2024

Fabriquer une couche raster à partir d'une géométrie avec PostGIS

Cet article présente une procédure de manipulation de données SIG avec PostGIS afin de fabriquer une couche raster de densité.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus