Skip to content

Instantly share code, notes, and snippets.

@PowerKiKi
Last active December 23, 2020 20:21
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save PowerKiKi/b8ecd4bdfb3f4d694a1370e3c57a5062 to your computer and use it in GitHub Desktop.
Save PowerKiKi/b8ecd4bdfb3f4d694a1370e3c57a5062 to your computer and use it in GitHub Desktop.
Angular Universal SSR with i18n - `yarn dev-ssr` does not work (yet), but production does work and so should pm2
# Everything else that does not exists on disk redirect to Angular
location ~ ^/(?<selectedLanguage>ar|br|cs|de|en|es|fr|is|it|ku|lb|pl|pt|zh) {
# If bot, give a SSR prerendered page
error_page 419 = @ssr;
if ($http_user_agent ~* "yahoo|bingbot|baiduspider|yandex|yeti|yodaobot|gigabot|facebookexternalhit|twitterbot") {
return 419;
}
try_files $uri /$selectedLanguage/index.html?$args;
}
# Angular Universal SSR, served by node via proxy
location @ssr {
proxy_pass http://localhost:9003;
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
diff --git angular.json angular.json
index bbdde14d..c2d16dc5 100644
--- angular.json
+++ angular.json
@@ -133,7 +133,12 @@
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
- "tsConfig": ["tsconfig.app.json", "tsconfig.spec.json", "e2e/tsconfig.json"],
+ "tsConfig": [
+ "tsconfig.app.json",
+ "tsconfig.spec.json",
+ "e2e/tsconfig.json",
+ "tsconfig.server.json"
+ ],
"exclude": ["**/node_modules/**"]
}
},
@@ -149,6 +154,53 @@
}
}
},
+ "server": {
+ "builder": "@angular-devkit/build-angular:server",
+ "options": {
+ "outputPath": "data/tmp/server",
+ "main": "server.ts",
+ "tsConfig": "tsconfig.server.json"
+ },
+ "configurations": {
+ "production": {
+ "outputHashing": "media",
+ "fileReplacements": [
+ {
+ "replace": "client/environments/environment.ts",
+ "with": "client/environments/environment.prod.ts"
+ }
+ ],
+ "sourceMap": false,
+ "optimization": true,
+ "i18nMissingTranslation": "ignore",
+ "localize": true
+ }
+ }
+ },
+ "serve-ssr": {
+ "builder": "@nguniversal/builders:ssr-dev-server",
+ "options": {
+ "browserTarget": "theodia:build",
+ "serverTarget": "theodia:server"
+ },
+ "configurations": {
+ "production": {
+ "browserTarget": "theodia:build:production",
+ "serverTarget": "theodia:server:production"
+ }
+ }
+ },
+ "prerender": {
+ "builder": "@nguniversal/builders:prerender",
+ "options": {
+ "browserTarget": "theodia:build:production",
+ "serverTarget": "theodia:server:production",
+ "routes": ["/"]
+ },
+ "configurations": {
+ "production": {}
+ }
+ },
"xliffmerge": {
"builder": "@ngx-i18nsupport/tooling:xliffmerge",
"options": {
diff --git client/app/app-routing.module.ts client/app/app-routing.module.ts
index 38d1623d..f5aacc7e 100644
--- client/app/app-routing.module.ts
+++ client/app/app-routing.module.ts
@@ -29,7 +29,7 @@ const routes: Routes = [
];
@NgModule({
- imports: [RouterModule.forRoot(routes)],
+ imports: [RouterModule.forRoot(routes, {initialNavigation: 'enabled'})],
exports: [RouterModule],
})
export class AppRoutingModule {}
diff --git client/app/app.module.ts client/app/app.module.ts
index 4cbb7db6..ed491e0e 100644
--- client/app/app.module.ts
+++ client/app/app.module.ts
@@ -1,5 +1,5 @@
import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http';
-import {Inject, LOCALE_ID, NgModule} from '@angular/core';
+import {Inject, LOCALE_ID, NgModule, PLATFORM_ID} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {NaturalAlertModule, NaturalAlertService} from '@ecodev/natural';
@@ -10,11 +10,11 @@ import {InMemoryCache} from 'apollo-cache-inmemory';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {BootLoaderComponent} from './shared/components/boot-loader/boot-loader.component';
-import {apolloDefaultOptions, createApolloLink} from './shared/config/apolloDefaultOptions';
+import {apolloDefaultOptions, createApolloLink, createApolloLinkForServer} from './shared/config/apolloDefaultOptions';
import {NetworkActivityService} from './shared/services/network-activity.service';
import {NetworkInterceptorService} from './shared/services/network-interceptor.service';
import {ssrCompatibleStorageProvider} from './shared/utils';
-import {DatePipe} from '@angular/common';
+import {DatePipe, isPlatformBrowser} from '@angular/common';
import {MatPaginatorIntl} from '@angular/material/paginator';
import {LocalizedPaginatorIntlService} from './shared/services/localized-paginator-intl.service';
@@ -24,7 +24,7 @@ import {LocalizedPaginatorIntlService} from './shared/services/localized-paginat
ApolloModule,
AppRoutingModule,
BrowserAnimationsModule,
- BrowserModule,
+ BrowserModule.withServerTransition({appId: 'serverApp'}),
HttpBatchLinkModule,
HttpClientModule,
NaturalAlertModule,
@@ -51,13 +51,21 @@ export class AppModule {
alertService: NaturalAlertService,
httpBatchLink: HttpBatchLink,
@Inject(LOCALE_ID) locale: string,
+ // tslint:disable-next-line:ban-types
+ @Inject(PLATFORM_ID) readonly platformId: Object,
) {
- const link = createApolloLink(networkActivityService, alertService, httpBatchLink, locale);
+ const isBrowser = isPlatformBrowser(platformId);
+ const language = locale.split('-')[0];
+
+ const link = isBrowser
+ ? createApolloLink(networkActivityService, alertService, httpBatchLink, language)
+ : createApolloLinkForServer(httpBatchLink, language);
apollo.create({
link,
cache: new InMemoryCache(),
defaultOptions: apolloDefaultOptions,
+ ssrMode: !isBrowser,
});
}
}
diff --git client/app/app.server.module.ts client/app/app.server.module.ts
new file mode 100644
index 00000000..eace9b89
--- /dev/null
+++ client/app/app.server.module.ts
@@ -1,9 +1,9 @@
+import {NgModule} from '@angular/core';
+import {ServerModule} from '@angular/platform-server';
+
+import {AppModule} from './app.module';
+import {AppComponent} from './app.component';
+import {FlexLayoutServerModule} from '@angular/flex-layout/server';
+
+@NgModule({
+ imports: [AppModule, ServerModule, FlexLayoutServerModule],
+ bootstrap: [AppComponent],
+})
+export class AppServerModule {}
diff --git client/main.server.ts client/main.server.ts
new file mode 100644
index 00000000..eace9b89
--- /dev/null
+++ client/main.server.ts
@@ -0,0 +1,10 @@
+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';
diff --git client/main.ts client/main.ts
index ca3ca22a..828f442d 100644
--- client/main.ts
+++ client/main.ts
@@ -8,6 +8,8 @@ if (environment.production) {
enableProdMode();
}
-platformBrowserDynamic()
- .bootstrapModule(AppModule)
- .catch(err => console.error(err));
+document.addEventListener('DOMContentLoaded', () => {
+ platformBrowserDynamic()
+ .bootstrapModule(AppModule)
+ .catch(err => console.error(err));
+});
diff --git configuration/ecosystem.config.js configuration/ecosystem.config.js
new file mode 100644
index 00000000..008890de
--- /dev/null
+++ configuration/ecosystem.config.js
@@ -0,0 +1,23 @@
+module.exports = {
+ apps: [{
+ name: 'theodia-angular-universal-ssr',
+ script: './server.run.js',
+ cwd: __dirname + '/..',
+
+ // Options reference: https://pm2.io/doc/en/runtime/reference/ecosystem-file/
+ instances: 1,
+ autorestart: true,
+ watch: [
+ './data/tmp/server',
+ './configuration',
+ ],
+ out_file: './logs/angular-universal-ssr.log',
+ max_memory_restart: '100M',
+ env: {
+ NODE_ENV: 'development',
+ },
+ env_production: {
+ NODE_ENV: 'production',
+ },
+ }],
+};
diff --git package.json package.json
index f2dcefad..b43cabfe 100644
--- package.json
+++ package.json
@@ -6,14 +6,17 @@
"ng": "ng",
"prerequisite": "yarn codegen",
"dev": "yarn prerequisite && ng serve --configuration fr",
- "prod": "yarn prerequisite && ng build --prod && ng build theodia-widget --prod && ./bin/move-build.php",
+ "prod": "yarn prerequisite && ng build --prod && ng build theodia-widget --prod && ./bin/move-build.php && ng run theodia:server:production",
"dev-widget": "yarn prerequisite && ng serve theodia-widget",
"prod-widget": "yarn prerequisite && ng build theodia-widget --prod && rm -rf htdocs/widget/* && mv data/tmp/build-widget/* htdocs/widget",
"test": "yarn prerequisite && ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"i18n-extract": "ng xi18n --ivy --output-path data/tmp --out-file messages.xlf && ng run theodia:xliffmerge",
- "codegen": "./bin/dump-schema && apollo client:codegen -c apollo.config.js --outputFlat --target typescript client/app/shared/generated-types.ts"
+ "codegen": "./bin/dump-schema && apollo client:codegen -c apollo.config.js --outputFlat --target typescript client/app/shared/generated-types.ts",
+ "dev-ssr": "ng run theodia:serve-ssr",
+ "serve-ssr": "node server.run.js",
+ "prerender": "ng run theodia:prerender"
},
"dependencies": {
"@angular/animations": "~10.1.2",
@@ -32,6 +35,7 @@
"@ecodev/fab-speed-dial": "^6.0.0",
"@ecodev/natural": "^23.3.0",
"@graphql-tools/mock": "^6.0.14",
+ "@nguniversal/express-engine": "^10.1.0",
"@ngx-i18nsupport/tooling": "^8.0.3",
"apollo": "^2.30.0",
"apollo-angular": "^1.10.0",
@@ -44,6 +48,7 @@
"apollo-link-schema": "^1.2.5",
"apollo-upload-client": "^13.0.0",
"autolinker": "^3.14.1",
+ "express": "^4.15.2",
"graphql": "^15.3.0",
"graphql-tag": "^2.10.4",
"lodash-es": "^4.17.15",
@@ -59,8 +64,10 @@
"@angular-devkit/build-angular": "~0.1001.2",
"@angular/cli": "~10.1.2",
"@angular/compiler-cli": "~10.1.2",
+ "@nguniversal/builders": "^10.1.0",
"@ngx-i18nsupport/ngx-i18nsupport": "^1.1.6",
"@types/apollo-upload-client": "^8.1.3",
+ "@types/express": "^4.17.0",
"@types/googlemaps": "^3.39.13",
"@types/gtag.js": "^0.0.3",
"@types/jasmine": "~3.5.14",
diff --git server.run.js server.run.js
new file mode 100644
index 00000000..cafd11d5
--- /dev/null
+++ server.run.js
@@ -0,0 +1,51 @@
+const {readFileSync, existsSync} = require('fs');
+const {createProxyMiddleware} = require('http-proxy-middleware');
+
+const express = require('express');
+
+/**
+ * Return the list of supported and actually active locales
+ */
+function getActiveLocales() {
+ const angularConfig = JSON.parse(readFileSync('angular.json', 'utf8'));
+
+ const supportedLocales = [
+ angularConfig.projects.theodia.i18n.sourceLocale,
+ ...Object.keys(angularConfig.projects.theodia.i18n.locales),
+ ];
+
+ return supportedLocales.filter(locale => existsSync(`htdocs/${locale}`));
+}
+
+function app() {
+ const server = express();
+
+ // Share the same proxy as non-SSR development mode for SSR development mode
+ // But SSR production mode will not use this and instead directly hit nginx
+ const proxyConfig = JSON.parse(readFileSync('proxy.conf.json', 'utf8'));
+ Object.entries(proxyConfig).forEach(([route, config]) => {
+ const c = {...config, changeOrigin: true};
+ server.use(route, createProxyMiddleware(c));
+ console.log(route, c);
+ });
+
+ getActiveLocales().forEach(locale => {
+ console.log('serving locale:', locale);
+
+ const appServerModule = require(`./data/tmp/server/${locale}/main.js`);
+ server.use(`/${locale}`, appServerModule.app(locale));
+ });
+
+ return server;
+}
+
+function run() {
+ const port = process.env.PORT || 9003;
+
+ // Start up the Node server
+ app().listen(port, () => {
+ console.log(`Node Express server listening on http://localhost:${port}`);
+ });
+}
+
+run();
diff --git server.ts server.ts
new file mode 100644
index 00000000..0a575ffd
--- /dev/null
+++ server.ts
@@ -0,0 +1,70 @@
+import 'zone.js/dist/zone-node';
+
+import {ngExpressEngine} from '@nguniversal/express-engine';
+import * as express from 'express';
+import {join} from 'path';
+
+import {AppServerModule} from './client/main.server';
+import {APP_BASE_HREF} from '@angular/common';
+import {existsSync, readFileSync} from 'fs';
+import {Express} from 'express';
+
+// The Express app is exported so that it can be used by serverless Functions.
+export function app(locale: string): Express {
+ const server = express();
+ const distFolder = join(process.cwd(), `htdocs/${locale}`);
+ const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
+
+ // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
+ server.engine(
+ 'html',
+ ngExpressEngine({
+ bootstrap: AppServerModule,
+ }),
+ );
+
+ server.set('view engine', 'html');
+ server.set('views', distFolder);
+
+ // Example Express Rest API endpoints
+ // server.get('/api/**', (req, res) => { });
+ // Serve static files from /browser
+ server.get(
+ '*.*',
+ express.static(distFolder, {
+ maxAge: '1y',
+ }),
+ );
+
+ // All regular routes use the Universal engine
+ server.get('*', (req, res) => {
+ res.render(indexHtml, {req, providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl}]});
+ });
+
+ return server;
+}
+
+/**
+ * This will be used for SSR development mode
+ */
+function run(): void {
+ const port = process.env.PORT || 9003;
+
+ // Start up the Node server
+ const server = app('fr');
+ 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')) {
+ run();
+}
+
+export * from './client/main.server';
diff --git tsconfig.server.json tsconfig.server.json
new file mode 100644
index 00000000..a35ab9df
--- /dev/null
+++ tsconfig.server.json
@@ -0,0 +1,13 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.app.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/server",
+ "target": "es2016",
+ "types": ["node"]
+ },
+ "files": ["client/main.server.ts", "server.ts"],
+ "angularCompilerOptions": {
+ "entryModule": "./src/app/app.server.module#AppServerModule"
+ }
+}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment