使用 firebase 云函数提供具有国际化的 angular 10 ssr 通用应用程序
Serving an angular 10 ssr universal app with i18n using firebase cloud functions
我正在开发一个 monorepo 项目,使用 angular 10 和 nx(托管在 firebase 上)由 3 个应用程序组成:网站、应用程序和管理。
该网站和应用程序使用内置的@angular/localize 包进行国际化。
现在,我正在网站中实施 angular 通用,但每次我尝试从我的域访问任何 url 时,我的 https 云功能都会超时。
这是我到目前为止所做的:
- 在/apps/website/src
中添加了main.server.ts
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';
- 在 apps/website/src/app
中添加了 app.server.module.ts
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 {}
- 在 /apps/website
中添加了 tsconfig.server.json
{
"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"
}
}
- 在/apps/website
中添加了server.js
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';
- 在 /apps/functions/src/app/ssr-website 中添加了 index.ts 以定义将部署的云函数:
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);
为了构建我的 ssr 应用程序,我使用以下 npm 命令:
npm run deploy:pp:functions
来自我的 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"
...
基本上,它构建 ssr 应用程序,将 dist/website 文件夹复制到 apps/functions,构建云功能,然后将其部署到 firebase。
这里是 angular.json 配置:
{
"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": {}
}
}
}
}
}
}
构建完成后,一个 /dist 文件夹已创建,结构如下:
dist/
└───website/
│ └───browser/
│ │ └───en/
│ │ └───fr/
│ └───server/
│ └───en/
│ └───fr/
在将 dist/website/browser 上传到主机之前,我删除了 /dist/website/browser/en 中的 index.html 文件和/dist/website/browser/fr 以确保主机提供 https 功能(不是 index.html 文件)。
最后,这是我的 firebase 配置 (firebase.json):
{
...
"hosting": [
...
{
"target": "website",
"public": "dist/website/browser",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"function": "universal-globalSsr"
}
]
},
...
],
...
}
如前所述,一切都按预期构建、打包和部署。一旦我尝试访问 https://www.my-domaine.com/fr/,我的函数就会执行,但我的日志中出现服务器超时,没有任何错误。
如果我尝试访问不存在的 url(例如:https://www.my-domaine.com/fr/foo),我会收到错误消息“无法匹配任何路由。URL 段:'foo'”,然后超时。
至此,我不知道我的代码有什么问题or/and我的项目配置。
任何帮助将不胜感激。
对于那些在使用 Angular universal with firebase 时服务器卡在无限加载状态的人,我的问题来自我的应用程序中的特定 firestore 请求
在我的项目中,我使用@angular/fire 和 Rxjs。在应用程序初始化时,我在我的一项服务中向 pre-cache 配置对象发出请求,类似于:
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;
});
管道中的 take(1)
操作员出于某种原因负责控制服务器端。删除 take(1)
解决了问题。
我发现了这个问题,其中某些特定类型的 firestore 请求正在破坏 ssr(有关此事的更多信息):https://github.com/angular/angularfire/issues/2420
我正在开发一个 monorepo 项目,使用 angular 10 和 nx(托管在 firebase 上)由 3 个应用程序组成:网站、应用程序和管理。 该网站和应用程序使用内置的@angular/localize 包进行国际化。
现在,我正在网站中实施 angular 通用,但每次我尝试从我的域访问任何 url 时,我的 https 云功能都会超时。
这是我到目前为止所做的:
- 在/apps/website/src 中添加了main.server.ts
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';
- 在 apps/website/src/app 中添加了 app.server.module.ts
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 {}
- 在 /apps/website 中添加了 tsconfig.server.json
{
"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"
}
}
- 在/apps/website 中添加了server.js
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';
- 在 /apps/functions/src/app/ssr-website 中添加了 index.ts 以定义将部署的云函数:
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);
为了构建我的 ssr 应用程序,我使用以下 npm 命令:
npm run deploy:pp:functions
来自我的 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"
...
基本上,它构建 ssr 应用程序,将 dist/website 文件夹复制到 apps/functions,构建云功能,然后将其部署到 firebase。
这里是 angular.json 配置:
{
"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": {}
}
}
}
}
}
}
构建完成后,一个 /dist 文件夹已创建,结构如下:
dist/
└───website/
│ └───browser/
│ │ └───en/
│ │ └───fr/
│ └───server/
│ └───en/
│ └───fr/
在将 dist/website/browser 上传到主机之前,我删除了 /dist/website/browser/en 中的 index.html 文件和/dist/website/browser/fr 以确保主机提供 https 功能(不是 index.html 文件)。
最后,这是我的 firebase 配置 (firebase.json):
{
...
"hosting": [
...
{
"target": "website",
"public": "dist/website/browser",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"function": "universal-globalSsr"
}
]
},
...
],
...
}
如前所述,一切都按预期构建、打包和部署。一旦我尝试访问 https://www.my-domaine.com/fr/,我的函数就会执行,但我的日志中出现服务器超时,没有任何错误。 如果我尝试访问不存在的 url(例如:https://www.my-domaine.com/fr/foo),我会收到错误消息“无法匹配任何路由。URL 段:'foo'”,然后超时。
至此,我不知道我的代码有什么问题or/and我的项目配置。
任何帮助将不胜感激。
对于那些在使用 Angular universal with firebase 时服务器卡在无限加载状态的人,我的问题来自我的应用程序中的特定 firestore 请求
在我的项目中,我使用@angular/fire 和 Rxjs。在应用程序初始化时,我在我的一项服务中向 pre-cache 配置对象发出请求,类似于:
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;
});
管道中的 take(1)
操作员出于某种原因负责控制服务器端。删除 take(1)
解决了问题。
我发现了这个问题,其中某些特定类型的 firestore 请求正在破坏 ssr(有关此事的更多信息):https://github.com/angular/angularfire/issues/2420