Angular Universal sur Firebase

On passe au front ?

Maintenant qu’on a une super API REST qui tient la charge, si on passait à un front auquel on tenterait d’appliquer les même contraintes ?

Ici on va tenter de créer un projet Angular classique, le faire héberger par Firebase Hosting et comme on aimerait que le site soit indexé correctement par les bots on va utiliser Firebase Cloud Functions pour faire du server-side-rendering.

Les sources du projet sont disponibles sur Gitlab : https://gitlab.com/gautier-levert/firebase-universal

Création du projet

Pour créer le projet Angular, sans surprise on va utiliser Angular CLI

1
ng new firebase-universal

Mon objectif n’est pas de vous montrer tout le fonctionnement d’Angular, du coup si vous avez besoin d’un tuto sur le sujet je vous renvoie vers le tutoriel officiel.

Ce qui nous intéresse ici c’est l’hébergement et on va avoir besoin de créer un projet sur la console Firebase.

La création d’un projet est assez guidée et ne demande que quelques clics, je pense que vous saurez vous débrouiller pour arriver à cet écran :

Projet créé

Configuration du déploiement

Les choses sérieuses commencent : on a notre application Angular et on a un projet Firebase pour la déployer.

Pour cette étape vous aurez besoin du CLI de Firebase.

Pour le connecter à son compte Google on utilise la commande :

1
firebase login

Une fois le CLI bien installé et configuré on se place à la racine du projet Angular et on lance la commande :

1
firebase init

Et là tout est guidé, on sélectionne donc Hosting puis Use an existing project et enfin on sélectionne le nom de notre projet Firebase.

Firebase init

Vient maintenant le moment de la configuration du dossier public, il va falloir changer la configuration par défaut par votre dossier de build. Par défaut il s’agit de dist/$nom-du-projet (remplacer $nom-du-projet par le nom de votre projet Angular). On peut retrouver ce nom de dossier dans le fichier angular.json à l’arborescence projects > architect > build > options > outputPath. À la question Configure as a single-page app (rewrite all urls to /index.html)? il faut répondre oui, c’est important pour que le routing Angular fonctionne correctement. Et enfin ce n’est pas la peine de remplacer le fichier index.html.

Voilà ce que ça donne sur mon projet :

Firebase configuration hosting

Bon bah plus qu’à déployer :

1
2
ng build
firebase deploy

et voilà c’est en ligne : https://angular-universal-f04ce.web.app/ simple, non ?

L’indexation Google

Le vrai problème des applications web dont le rendu est fait par JavaScript (comme Angular, React, Vue, etc) c’est leur indexation par Google (et les autres bots).

Le bot est capable d’exécuter un peu de JS mais les scripts seront interrompus au bout de quelques secondes et il y a très peu de chances que votre site fonctionne correctement dans ces conditions.

Heureusement, ces frameworks ont la solution : faire le rendu des pages côté serveur (ou server side rendering avec votre plus bel accent anglais) ! Dans le cas d’Angular, cette technique porte le nom de Angular Universal.

Pour ajouter Angular Universal dans notre projet on se place dans la dossier racine et on tape la ligne suivante (attention de bien remplacer firebase-universal par le nom de votre projet, celui de ng new)

1
ng add @nguniversal/express-engine --client-project=firebase-universal 

Cette feature rajoute pas mal de fichiers, il s’agit du code de configuration du serveur express pour le rendu des pages.

Essayons maintenant de tester tout ça avec yarn

1
2
yarn build:ssr
yarn serve:ssr

ou si vous utilisez npm :

1
2
npm run build:ssr
npm run serve:ssr

Maintenant vous pouvez lancer votre navigateur préféré, vous rendre sur l’URL donnée : par défaut http://localhost:4000/ Vous pouvez même essayer de vous rendre sur la page en désactivant complètement JavaScript et le rendu serait le même.

Faire tourner sur Cloud Functions

Si vou possédez votre propre serveur équipé de node, vous pouvez donc vous contentez de faire tourner cette application et votre problème est déjà résolu.

Mais on veut aller plus loin, on ne veut pas avoir à gérer de serveur. On va donc configurer Cloud Functions pour faire tourner tout ça.

1
firebase init

On sélectionne Cloud Functions et on répond aux questions du wizard. J’ai choisi ici de coder la fonction en TypeScript mais ce n’est pas du tout obligatoire.

Firebase configuration functions

On se retrouve avec un nouveau dossier functions c’est un nouveau module node dans lequel on va écrire le code qui sera déployé sur Firebase.

Si comme moi vous utilisez yarn, il est probable que yarn install ne fonctionne pas pour des histoires de version de node incompatible (CLoud Functions tourne actuellement avec node 10 tandis que la dernière version stable en date est la 14).

Pour régler ce problème on peut soit exécuter yarn avec une version de node 10 ou alors forcer l’installation yarn install --ignore-engines. Je n’ai jamais rencontré de problème de compatibilité mais on ne sait jamais.

Maintenant que tout ça est en place on va pouvoir commencer à coder la fonction à proprement parler.

Changer la configuration Angular

Je vais faire en sorte que la compilation des bundles Angular génère les fichiers dans un dossier à l’intérieur du module functions

Je change donc les valeurs projects > firebase-universal > architect > build > options > outputPath par "functions/lib/dist/browser" et projects > firebase-universal > architect > server > options > outputPath par "functions/lib/dist/server". Attention, ici firebase-universal correspond au nom de mon projet et sera certainement différent pour le votre.

Donc à partir de maintenant, quand one génère les bundles à l’aide de la task build:client-and-server-bundles on vient peupler un dossier lib à l’intérieur de functions. Ce qui permet d’accéder à ces bundle depuis notre fonction de rendering.

Changer la configuration Firebase

Il faut également mettre à jour le fichier firebase.json et changer le dossier à uploader pour la partie Hosting hosting > public pour y mettre le dossier du bundle browser "functions/lib/dist/browser" et on vient enfin modifier la partie hosting > rewrites pour demander l’exécution de la fonction ssr. Et enfin changer le script de déploiement pour être sûr de générer les bundles.

Avec toutes ces modifications, on se retrouve avec quelque chose comme ça :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "hosting": {
    "public": "functions/lib/dist/browser",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "ssr"
      }
    ]
  },
  "functions": {
    "predeploy": [
      "npm run build:client-and-server-bundles",
      "npm --prefix \"$RESOURCE_DIR\" run lint",
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ]
  }
}

Coder la fonction ssr

Pour cette partie on doit gérer les dépendances du module functions. Pour ça, la seule manière que j’ai trouvé pour obtenir quelque chose de fonctionnel est de copier les dépendances depuis le fichier package.json du projet racine et de les coller dans le fichier package.json du module functions.

Enfin on code la fonction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import * as express from 'express';
import * as functions from 'firebase-functions';
import {join} from 'path';
import 'zone.js/dist/zone-node';

// Express server
const app = express();

const DIST_FOLDER = join(process.cwd(), 'lib/dist/browser');

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

// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,
  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}));

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

// Serve static files from /browser
app.get('*.*', express.static(DIST_FOLDER, {
  maxAge: '1y'
}));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
  res.render('index', {req});
});

exports.ssr = functions.https.onRequest(app);

On voit que ce fichier reprend presque entièrement le fichier server.ts qui se trouve à la racine du projet. On a simplement mis à jour les chemins des dossiers et remplacé le fait d’écouter sur un port de la machine locale par la définition d’un handler de Cloud Functions.

Bonus : rendu du chemin racine

Par défaut, quand on demande un fichier existant dans la partie Hosting de Firebase il nous est renvoyé directement. C’est pratique, ça nous évite de payer une utilisation de notre Cloud Function pour juste servir des fichiers statiques. Ça devient par contre plus embêtant quand un lien pointe sur la racine du site : Firebase va alors fournir le fichier index.html sans le rendu côté serveur !

Pas de panique, j’ai la solution et elle est plutôt simple. Il suffit de demander à firebase d’ignorer le fichier index.html lors de l’upload de la partie hosting (tout en le conservant dans la partie Functions).

Dans le fichier firebase.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "hosting": {
  
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**",
      "index.html"
    ]

  }
}

Bonus : ne faire le rendu que pour les bots

Une astuce pour éviter de consommer des ressources Cloud Functions inutilement, c’est d’éviter d’effectuer le rendu côté serveur quand c’est inutile (donc quand le client n’est pas un bot).

Il suffit de modifier un peu la fonction functions/src/index.ts :

1
2
3
4
5
6
7
app.get('*', (req, res) => {
  if (isBot(req.headers['user-agent'] as string)) {
    res.render('index', {req});
  } else {
    res.sendFile(join(DIST_FOLDER, 'index.html'));
  }
});

On voit que le code n’est pas très compliqué, si on considère que le client est un bot alors on fait le prérendu du template, sinon on sert le fichier index.html directement en s’épargnant le processus de rendu.

Et voici la méthode isBot qui est sans doute améliorable mais remplit plutôt bien sa fonction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function isBot(userAgent: string): boolean {
  // Lists of bots to target
  const bots = [
    // crawler bots
    'Googlebot', // Google
    'AdsBot', // Google
    'bingbot', // Bing
    'BingPreview', // Bing
    'YandexBot', // Yandex
    'DuckDuckBot',
    'Slurp', // Yahoo!
    'Qwantify',
    'Sogou',
    'proximic',
    'Baiduspider',
    'Teoma',
    'AhrefsBot',
    // link bots
    'twitterbot',
    'facebookexternalhit',
    'linkedinbot',
    'embedly',
    'baiduspider',
    'pinterest',
    'slackbot',
    'vkShare',
    'facebot',
    'outbrain',
    'W3C_Validator'
  ];

  const agent = userAgent ? userAgent.toLowerCase() : '';

  if (bots.some(bot => {
    if (agent.indexOf(bot.toLowerCase()) > -1) {
      console.log(`bot detected: ${bot} / ${userAgent}`);
      return true;
    }
    return false;
  })) {
    return true;
  }

  console.log(`no bot found: ${userAgent}`);
  return false;
}

Déploiement

Si tout va bien, vous n’avez plus qu’à taper la commande firebase deploy à la racine du projet pour tout mettre en ligne et laisser la magie opérer.

Load Comments?