1
votes

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.

1
This is quite a setup you got. Does the function complete properly? Did you check your App Engine timeout settings? I would say add more logging and simplify your application to narrow down the issue.Emil Gi
The function execution only finish with a timeout, after 1min. It seems that the res.render function doesn't return anything. The console.log declared prior to this res.render get printed in the logs by the way. I already made a simple poc using just universal and firebase. The app only contained two simple routes. And it worked as intended. The server returned a page with the rendered html. In this project, there's i18n in addition. It seems that the problem comes from the localization configuration. I already made several changes to see if it makes any changes, without success.Quentin Fonck
Is there anything else in function logs? res.render never executes? If you add console.log after it will it appear in logs?Emil Gi
@EmilGi yes, the console.log, placed after, appears in the logs as well.Quentin Fonck
Can I ask what package versions you have, and were you able to get firebase serve to load the routes? Which version of firebase and angularfire are you using?inorganik

1 Answers

2
votes

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