Serve un'app universale angolare 10 ssr con i18n utilizzando le funzioni cloud di Firebase
Sto lavorando a un progetto monorepo, utilizzando angular 10 e nx (ospitato su firebase) composto da 3 applicazioni: sito Web, app e admin. Il sito Web e l'app hanno l'internazionalizzazione utilizzando il pacchetto @angular/localize integrato.
Ora sto implementando angular universal nel sito Web, ma continuo a ricevere un timeout dalle mie funzioni cloud https ogni volta che provo ad accedere a qualsiasi URL dal mio dominio.
Ecco cosa ho fatto finora:
- Aggiunto 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';
- Aggiunto 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 {}
- Aggiunto 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"
}
}
- Aggiunto 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';
- Aggiunto index.ts in /apps/functions/src/app/ssr-website per definire la funzione cloud che verrà distribuita:
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);
Per creare la mia applicazione ssr, utilizzo il seguente comando npm: npm run deploy:pp:functionsdal mio 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"
...
Fondamentalmente, crea l'applicazione ssr, copia la cartella dist/website in apps/functions , crea la funzione cloud, quindi la distribuisce su Firebase.
Ecco angular.json per le configurazioni:
{
"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": {}
}
}
}
}
}
}
Una volta completata la compilazione, è stata creata una cartella /dist con questa struttura:
dist/
└───website/
│ └───browser/
│ │ └───en/
│ │ └───fr/
│ └───server/
│ └───en/
│ └───fr/
Prima di caricare dist/website/browser sull'hosting, elimino i file index.html in /dist/website/browser/en e /dist/website/browser/fr per assicurarmi che l'hosting serva la funzione https (non l'indice file .html).
Infine, ecco la mia configurazione per Firebase (firebase.json):
{
...
"hosting": [
...
{
"target": "website",
"public": "dist/website/browser",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"function": "universal-globalSsr"
}
]
},
...
],
...
}
Come accennato in precedenza, tutto viene creato, impacchettato e distribuito come previsto. Una volta che provo ad accederehttps://www.my-domaine.com/fr/, la mia funzione viene eseguita ma ottengo un timeout del server nei miei registri senza alcun errore. Se provo ad accedere a un URL che non esiste (es:https://www.my-domaine.com/fr/foo), viene visualizzato un errore "Impossibile trovare una corrispondenza con alcun percorso. Segmento URL: 'foo'", quindi un timeout.
A questo punto, non so cosa c'è di sbagliato nel mio codice o/e nella configurazione del mio progetto.
Qualsiasi aiuto sarebbe molto apprezzato.
Risposte
Per coloro che hanno il loro server bloccato in uno stato di caricamento infinito durante l'utilizzo di Angular Universal con Firebase, il mio problema derivava da una specifica richiesta firestore nella mia app
Nel mio progetto, sto usando @angular/fire con Rxjs. All'inizializzazione dell'app, stavo facendo una richiesta in uno dei miei servizi per pre-memorizzare nella cache gli oggetti di configurazione, simile a questo:
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;
});
L' take(1)operatore nella pipe era responsabile di mantenere il lato server per qualche motivo. Rimuovendo il take(1)risolto il problema.
Ho riscontrato questo problema, in cui alcuni tipi specifici di richieste firestore violano l'ssr (per ulteriori informazioni sull'argomento):https://github.com/angular/angularfire/issues/2420