Search code examples
angularfirebasegoogle-cloud-functionsangular-universalangular-i18n

Serving an angular 10 ssr universal app with i18n using firebase cloud functions


I am working on a monorepo project, using angular 10 and nx (hosted on firebase) composed of 3 applications: website, app and admin. The website and the app have Internationalization using the built-in @angular/localize package.

Now, I am implementing angular universal into the website but I keep getting a timeout from my https cloud functions every time I try to access any url from my domain.

Here's what I have done so far:

  • Added main.server.ts in /apps/website/src
import '@angular/localize/init';

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

import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

export { AppServerModule } from './app/app.server.module';
export { renderModule, renderModuleFactory } from '@angular/platform-server';
  • Added app.server.module.ts in apps/website/src/app
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

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

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

  • Added tsconfig.server.json in /apps/website
    {
      "extends": "./tsconfig.app.json",
      "compilerOptions": {
        "outDir": "../../dist/out-tsc-server",
        "module": "commonjs",
        "types": [
          "node"
        ]
      },
      "files": [
        "src/main.server.ts",
        "server.ts"
      ],
      "angularCompilerOptions": {
        "entryModule": "./src/app/app.server.module#AppServerModule"
      }
    }
  • Added server.js in /apps/website
import 'zone.js/dist/zone-node';

import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { join } from 'path';

import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { LOCALE_ID } from '@angular/core';

// The Express app is exported so that it can be used by serverless Functions.
// I pass a locale argument to fetch the correct i18n app in the browser folder
export function app(locale: string): express.Express {
  const server = express();
  // get the correct locale client app path for the server
  const distFolder = join(process.cwd(), `apps/functions/dist/website/browser/${locale}`);
  
  // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
  server.engine(
    'html',
    ngExpressEngine({
      bootstrap: AppServerModule,
      providers: [{provide: LOCALE_ID, useValue: locale}] // define locale_id for the server
    })
  );

  server.set('views', distFolder);
  server.set('view engine', 'html');
  // For static files
  server.get(
    '*.*',
    express.static(distFolder, {
      maxAge: '1y',
    })
  );


  // For route paths
  // All regular routes use the Universal engine
  server.get('*', (req, res) => {
    // this line always shows up in the cloud function logs
    console.log(`serving request, with locale ${locale}, base url: ${req.baseUrl}, accept-language: ${req.headers["accept-language"]}`);
    res.render('index.html', {
      req,
      providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
    });
  });

  return server;
}

// only used for testing in dev mode
function run(): void {
  const port = process.env.PORT || 4000;

  // Start up the Node server
  const appFr = app('fr');
  const appEn = app('en');
  const server = express();
  server.use('/fr', appFr);
  server.use('/en', appEn);
  server.use('', appEn);

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

// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = (mainModule && mainModule.filename) || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
  console.log('running server');
  run();
}

export * from './src/main.server';
  • Added index.ts in /apps/functions/src/app/ssr-website to define the cloud function that will get deploy:

    import * as functions from 'firebase-functions';
    const express = require("express");    
    const getTranslatedServer = (lang) => {
        const translatedServer = require(`../../../dist/website/server/${lang}/main`);
        return translatedServer.app(lang);
    };
        
    const appSsrEn = getTranslatedServer('en');
    const appSsrFr = getTranslatedServer('fr');
    
    // dispatch, as a proxy, the translated server app function to their coresponding url
    const server = express();
    server.use("/", appSsrEn); // use english version as default
    server.use("/fr", appSsrFr);
    server.use("/en", appSsrEn);
        
    export const globalSsr = functions.https.onRequest(server);

To build my ssr application, I use the following npm command: npm run deploy:pp:functions from my package.json:

...
"build:ppasprod:all-locales:website": "npm run fb:env:pp && ng build website -c=prod-core-optim,prod-budgets,pp-file-replace,all-locales",
"build:ssr:website": "npm run build:ppasprod:all-locales:website && ng run website:server:production",
"predeploy:website:functions": "nx workspace-lint && ng lint functions && node apps/functions/src/app/cp-universal.ts && ng build functions -c=production",
"deploy:pp:functions": "npm run fb:env:pp && npm run build:ssr:website && npm run predeploy:website:functions && firebase deploy --only functions:universal-globalSsr"
...

Basically, it builds the ssr application, copies the dist/website folder in apps/functions, builds the cloud function, then deploys it to firebase.

Here's the angular.json for the configurations:


    {
      "projects": {
        "website": {
          "i18n": {
            "locales": {
              "fr": "apps/website/src/locale/messages.fr.xlf",
              "en": "apps/website/src/locale/messages.en.xlf"
            }
          },
          "projectType": "application",
          "schematics": {
            "@nrwl/angular:component": {
              "style": "scss"
            }
          },
          "root": "apps/website",
          "sourceRoot": "apps/website/src",
          "prefix": "",
          "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                "outputPath": "dist/website/browser",
                "deleteOutputPath": false,
                "index": "apps/website/src/index.html",
                "main": "apps/website/src/main.ts",
                "polyfills": "apps/website/src/polyfills.ts",
                "tsConfig": "apps/website/tsconfig.app.json",
                "aot": true,
                "assets": [
                  "apps/website/src/assets",
                  {
                    "input": "libs/assets/src/lib",
                    "glob": "**/*",
                    "output": "./assets"
                  }
                ],
                "styles": [
                  "apps/website/src/styles.scss",
                  "libs/styles/src/lib/styles.scss"
                ],
                "scripts": [],
                "stylePreprocessorOptions": {
                  "includePaths": ["libs/styles/src/lib/"]
                }
              },
              "configurations": {
                "devlocal": {
                  "budgets": [
                    {
                      "type": "anyComponentStyle",
                      "maximumWarning": "6kb"
                    }
                  ]
                },
                "all-locales": {
                  "localize": ["en", "fr"]
                },
                "pp-core-optim": {
                  "optimization": false,
                  "i18nMissingTranslation": "error",
                  "sourceMap": true,
                  "statsJson": true
                },
                "pp-file-replace": {
                  "fileReplacements": [
                    {
                      "replace": "apps/website/src/environments/environment.ts",
                      "with": "apps/website/src/environments/environment.pp.ts"
                    }
                  ]
                },
                "prod-budgets": {
                  "budgets": [
                    {
                      "type": "initial",
                      "maximumWarning": "2mb",
                      "maximumError": "5mb"
                    },
                    {
                      "type": "anyComponentStyle",
                      "maximumWarning": "6kb",
                      "maximumError": "10kb"
                    }
                  ]
                },
                "prod-core-optim": {
                  "i18nMissingTranslation": "error",
                  "optimization": true,
                  "outputHashing": "all",
                  "sourceMap": false,
                  "extractCss": true,
                  "namedChunks": false,
                  "extractLicenses": true,
                  "vendorChunk": false,
                  "buildOptimizer": true
                }
              }
            },
            "extract-i18n": {
              "builder": "@angular-devkit/build-angular:extract-i18n",
              "options": {
                "browserTarget": "website:build"
              }
            },
            "server": {
              "builder": "@angular-devkit/build-angular:server",
              "options": {
                "outputPath": "dist/website/server",
                "main": "apps/website/server.ts",
                "tsConfig": "apps/website/tsconfig.server.json",
                "externalDependencies": ["@firebase/firestore"],
                "stylePreprocessorOptions": {
                  "includePaths": ["libs/styles/src/lib/"]
                }
              },
              "configurations": {
                "production": {
                  "outputHashing": "media",
                  "fileReplacements": [
                    {
                      "replace": "apps/website/src/environments/environment.ts",
                      "with": "apps/website/src/environments/environment.prod.ts"
                    }
                  ],
                  "sourceMap": false,
                  "optimization": true,
                  "localize": ["en", "fr"]
                }
              }
            },
            "serve-ssr": {
              "builder": "@nguniversal/builders:ssr-dev-server",
              "options": {
                "browserTarget": "website:build",
                "serverTarget": "website:server"
              },
              "configurations": {
                "production": {
                  "browserTarget": "website:build:production",
                  "serverTarget": "website:server:production"
                }
              }
            },
            "prerender": {
              "builder": "@nguniversal/builders:prerender",
              "options": {
                "browserTarget": "website:build:production",
                "serverTarget": "website:server:production",
                "routes": ["/"]
              },
              "configurations": {
                "production": {}
              }
            }
          }
        }
      }
    }

Once the build is done, a /dist folder has been created with this structure:

dist/
└───website/
│   └───browser/
│   │   └───en/
│   │   └───fr/
│   └───server/
│       └───en/
│       └───fr/

Before uploading the dist/website/browser to the hosting, I delete the index.html files in /dist/website/browser/en and /dist/website/browser/fr to make sure the hosting serve the https function (not the index.html file).

Finally, here's my configuration for firebase (firebase.json):

{
  ...
  "hosting": [
    ...
    {
      "target": "website",
      "public": "dist/website/browser",
      "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
      "rewrites": [
        {
          "source": "**",
          "function": "universal-globalSsr"
        }
      ]
    },
    ...
  ],
  ...
}

As mentioned earlier, everything get built, packaged and deployed as expected. Once I try to access https://www.my-domaine.com/fr/, my function get executed but I get a server timeout in my logs without any error. If I try to access a url that doesn't exist (ex: https://www.my-domaine.com/fr/foo), I get an error "Cannot match any routes. URL segment: 'foo'", then a timeout.

At this point, I don't know what is wrong with my code or/and my project configuration.
Any help would be greatly appreciated.


Solution

  • For those having their server stuck in an infinite loading state when using Angular universal with firebase, my problem came from a specific firestore request in my app

    In my project, I am using @angular/fire with Rxjs. At the app initialization, I was doing a request in one of my service to pre-cache config objects, similar to this:

     this.afs
         .collection<MyObject>(this.cl.COLLECTION_NAME_OBJECT)
         .snapshotChanges()
         .pipe(
           map((actions) =>
             actions.map((a) => {
               const data = a.payload.doc.data() as MyObject;
               const id = a.payload.doc.ref;
               return { id, ...data };
             })
           ),
           take(1)
         )
         .subscribe((objects: MyObjects[]) => {
           this.myObjects = objects;
         });
    

    The take(1) operator in the pipe was responsible for holding the server side for some reason. Removing the take(1)solved the problem.

    I found this issue, where some specific type of firestore requests are breaking the ssr (for further information on the matter): https://github.com/angular/angularfire/issues/2420