Angular 9: Using angular i18n along with server side rendering (angular universal)
Asked Answered
R

2

7

I'm in the process of migrating an angular 7 application to angular 9, which uses server side rendering (angular universal) and angular i18n for 2 languages (french and english).

In the old angular 7 process, since I was using AOT I had to do 5 builds for production:

  • 2 x client build (1 for french, 1 for english)
  • 2 x server build (1 for french, 1 for english)
  • build of server.ts

Then, in server.ts I was loading dynamically the correct server bundle

old server.ts

app.engine('html', (_, options: any, callback) => {

  const isFR= options.req.url.indexOf('site-fr') >= 0 ;
  const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = isFR ? require('./dist/server/fr/main') : require('./dist/server/en/main');

// Our index.html we'll use as our template
  const template = readFileSync(join(DIST_FOLDER, 'browser', isFR ? 'fr' : 'en', 'index.html')).toString();
  renderModuleFactory(AppServerModuleNgFactory, {
    // Our index.html
    document: template,

I migrated the app to angular 9, and now from what I understand in the documentation, only one client build is needed.

You can also provide the --localize option to the ng build command with your existing production configuration. In this case, the CLI builds all locales defined under i18n in the project configuration.

This seems to work for the client build, as 2 folders (fr and en are generated).

However, nowhere is there any mention of using SSR with i18n. So I'm ending up with one server.ts.

Here are the scripts I use to build and run the project

angular.json

"serve:ssr": "node dist/myproject/server/main.js",
"build:ssr": "ng build -c production --localize && ng run myproject:server:production"

new server.ts

// The Express app is exported so that it can be used by serverless Functions.
export function app(port) {
  const server = express();
  const distFolder = join(process.cwd(), 'dist/myproject/browser/fr');
  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,
  }));

Dist folder structure

- dist
  - myproject
    - browser
        - fr
          - index.html
          - *.js
        - en
          - index.html
          - *.js      
    - server
        - main.js

Note: I did see a closed github issue describing this problem, but the solution is basically to go back the way it was before, i.e. have 2 builds per language and also 2 builds for server.ts.

Surely there is another way?

Rustle answered 11/3, 2020 at 16:25 Comment(1)
ngx-translate is a much better solution for this. In my AppBrowserModule I used the TranslateHttpLoader. In my AppServerModule I used the TranslateJsonLoader. No page reload required!Mountebank
R
5

I found a solution involving just 2 builds. But running 2 instances of the server process is now needed.

Step #1: angular.json

Ensure your locales are correctly defined in angular.json and add a new allLocales target in my-project:server option.

I created a new allLocales target because I did not know how to combine production target with en and fr configurations. The point of doing this is to just have one server build with all language server generated.

For the front bundle, this is natively possible with ng build (i.e. ng build --configuration=production,fr,en or ng build --configuration=production --localize)

angular.json

 "projects": {
    "my-project": {
      "i18n": {
        "locales": {
          "en": {
            "translation": "src/locale/messages.en.xlf",
            "baseHref": ""
          },
          "fr": {
            "translation": "src/locale/messages.fr.xlf",
            "baseHref": ""
          }
        }
      },
      "root": "",
      "sourceRoot": "src",
      "projectType": "application",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            //...
          },
          "configurations": {
            "production": {
             //...
            },

            "en": {
              "localize": [
                "en"
              ]
            },


            "fr": {
              "localize": [
                "fr"
              ]
            }

          }
        },

        "server": {
          "builder": "@angular-devkit/build-angular:server",
          "options": {
            "outputPath": "dist/my-project/server",
            "main": "server.ts",
            "tsConfig": "tsconfig.server.json"
          },

          "configurations": {
            "production": {
              //...
            },

            "allLocales": {
              "outputHashing": "none",
              "optimization": false,
              "sourceMap": false,
              "namedChunks": false,
              "extractLicenses": true,
              "vendorChunk": false,
              "localize": [
                "en", "fr"
              ],
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ]
            }
          }
        },

Step #2: server.ts

Modify server.ts file to accept a language parameter. Each running insteace of the generated server bundle main.js will have its own port and language.

server.ts

//...
export function app(language) { //add language here
  const server = express();
  const distFolder = join(process.cwd(), 'dist/my-project/browser', language); //Use language here


//...

function run() {
  const port = process.env.PORT || 5006;
  const language = process.env.LANGUAGE || 'fr';

  // Start up the Node server
  const server = app(language); //Use the language here
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port} for language ${language}`);
    });

Step #3: Modify package.json

package.json

"build:ssr": "ng build -c production --localize && ng run my-project:server:allLocales"
"serve:ssr-en": "env PORT=5006 LANGUAGE=en node dist/my-project/server/en/main.js",
"serve:ssr-fr": "env PORT=5007 LANGUAGE=fr node dist/my-project/server/fr/main.js",

build:ssr will build the the client bundles for all languages and then build the server bundles for all languages server:ssr-XX will start the nodejs server for the port and language associated to language XX

Here is the structure

- dist
  - myproject
    - browser
        - fr
          - index.html
          - *.js
        - en
          - index.html
          - *.js      
    - server
        - fr
            - main.js
        - en
            - main.js

Step 4: Reverse proxy

If you are using a reverse proxy, do not forget to redirect all requests to the correct main.js instance

Note The build process is now much faster with angular 9, since there is only 2 builds.

Rustle answered 13/3, 2020 at 14:31 Comment(2)
How to make it running both language on single port without reverse proxy?Awful
Theoritically, you'd need to write another server script that would manually 'require' the main.js file corresponding to the language. Or see the other proposed answerRustle
O
4

This is how we managed to work around the problem in Angular 9 (universal & i18n): We use old webpack config and localize for ssr build. Build does browser & server configurations for each language on single build.

NOTE We had universal and i18n back in Angular 8 so you might have to check documents for Webpack server config.

package.json:

  • We used the old webpack server config which was still in Angular 8 Universal documents.
  • Build production enviroment with localize flag.
  • Build was fine but docker had problems with memory heap size so node space is increased

    ...
    "webpack:server": "webpack --config webpack.server.config.js --progress --colors",
    "build:prod": "ng build --configuration=production --localize",
    "build:server:prod": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng run APP-NAME:server:production",
    "build:client-and-server-bundles": "npm run build:prod && npm run build:server:prod",
    "build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
    "serve:ssr": "node dist/server.js"
    

angular.json (declare i18n locales in project settings):

Server options:

    ...
      "options": {
        "main": "src/main.server.ts",
        "tsConfig": "src/tsconfig.server.json",
        "localize": ["fi", "en", "sv"]
      }

server.ts

In server configuration we declared routes to all bundles

const routes = [
  {path: '/en/*', view: 'en/index', bundle: require('./dist/server/en/main')},
  {path: '/sv/*', view: 'sv/index', bundle: require('./dist/server/sv/main')},
  {path: '/*', view: 'fi/index', bundle: require('./dist/server/fi/main')}
];

Then comes a hacky workaround: Declare require methods for every locale builds server config with AppServerModule, Lazy module map, express engine and module map provider

const {AppServerModule: AppServerModuleFi, LAZY_MODULE_MAP: LAZY_MODULE_MAP_FI, ngExpressEngine: ngExpressEngineFi, provideModuleMap: provideModuleMapFi} = require('./dist/server/fi/main');
const {AppServerModule: AppServerModuleEn, LAZY_MODULE_MAP: LAZY_MODULE_MAP_EN, ngExpressEngine: ngExpressEngineEn, provideModuleMap: provideModuleMapEn} = require('./dist/server/en/main');
const {AppServerModule: AppServerModuleSv, LAZY_MODULE_MAP: LAZY_MODULE_MAP_SV, ngExpressEngine: ngExpressEngineSv, provideModuleMap: provideModuleMapSv} = require('./dist/server/sv/main');

Then for each route we are going to use earlier declared server config file with "dedicated" require configuration. Example for nglish build:

routes.forEach((route) => {
  if (route.path.startsWith('/en')) { // Check against path
  // EN routes
  app.get(route.path, (req, res) => {

    app.engine('html', ngExpressEngineEn({
      bootstrap: AppServerModuleEn,
      providers: [
        provideModuleMapEn(LAZY_MODULE_MAP_EN)
      ]
    }));
    app.set('view engine', 'html');
    app.set('views', join(DIST_FOLDER, 'browser'));

    res.render(route.view, {
      req,
      res,
      engine: ngExpressEngineEn({
        bootstrap: AppServerModuleEn,
        providers: [provideModuleMapEn(LAZY_MODULE_MAP_EN),
        { req, res }]
      })
    });
  });
Orly answered 7/5, 2020 at 8:17 Comment(3)
Great answer, thank you for sharing, you might want to show it to the dev team to give them direction, they have an open task but they haven't move forward with that since February.Melodee
Won't this create 3 servers for each locale? When you run the build, all main.js will contain the server.ts code?Negotiate
@Spock, Just double checked and there's one server for each locale. Also checked that code doesn't multiply in main.js files.Orly

© 2022 - 2024 — McMap. All rights reserved.