Last active
December 23, 2020 20:21
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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