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:
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';
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 {}
{
"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"
}
}
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';
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.
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