DEV Community

Cover image for Serving multilingual Angular application with ExpressJS
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Serving multilingual Angular application with ExpressJS

Previously we derived the locales file that contains all the language resources in preparation to isolate them. Our focus today is serving through NodeJs and ExpressJS server. We will serve different languages using cookies, and later relying on the URL. But before we dig in, one last benefit of our resources class.

Accessing resources from anywhere

Out of the box, Angular provides $localize adapter, but it is limited to i18n uses. Our res class can be used even if no locale is targeted, and language.ts is used directly. We have already made use of it in Error catching and toast messages. Here is a snippet of how it can be freely used:

// using the res class for any linguistic content

// extreme case of a warning when an upload file is too large
const size = Config.Upload.MaximumSize;
this.toast.ShowWarning(
  // empty code to fallback
  '',
  // fallback to a dynamically created message
  { text: Res.Get('FILE_LARGE').replace('$0', size)}
);

// where FILE_LARGE in locale/language is:
// FILE_LARGE: 'The size of the file is larger than the specified limit ($0 KB)'
Enter fullscreen mode Exit fullscreen mode

Note: The source files are in StackBlitz, but they don't necessarily work in StackBlitz, because the environment is too strict.

Language JavaScript file

We covered in a previous article the basics of how to inject an external configuration file into Angular and came to the conclusion that the best way is to place the javascript file in the index header. At this stage, we have no clear model that we need to cast to, so let's start with a simple script tag in index.html:

<script src="locale/language.js" defer></script>

For that to work in development, we'll add an asset to angular.json

// angular.json options/assets
{
    "glob": "*.js",
    "input": "src/locale",
    "output": "/locale"
}
Enter fullscreen mode Exit fullscreen mode

To make use of the JavaScript keys collection, we declare in our typescript. The res class is the only place that uses the keys, and app.module is the only place that uses the locale id. So let's place everything in res class:

// in res class, we declare the keys and locale_id
declare const keys: any;
declare const EXTERNAL_LOCALE_ID: string;

export class Res {
  // export the locale id somehow, a property shall do
  public static LOCALE_ID = EXTERNAL_LOCALE_ID;

  // using them directly: keys
  public static Get(key: string, fallback?: string): string {
    if (keys[key]) {
        return keys[key];
    }
    return fallback || keys.NoRes;
  }

// ...
}

// in app.module, we import the locale id
// ...
providers: [{provide: LOCALE_ID, useValue: Res.LOCALE_ID }]
Enter fullscreen mode Exit fullscreen mode

Angular Locale Package

But how do we import the locale from Angular packages? The easiest, most straightforward way is to do exactly the same as above. Add a script, and reference in angular.json. Assuming we want to have multiple locales available, then we include them all in assets:

{
  // initially, add them all
  "glob": "*.js",
  "input": "node_modules/@angular/common/locales/global",
  "output": "/locale"
}
Enter fullscreen mode Exit fullscreen mode

This means that the locales' files are copied to the host when we build, which is ideal, because this way we know we always have the latest version of the locale. One way is this:

<script src="locale/ar-JO.js" defer></script>

The other is to let the language file create the tag. Remember though, this file will eventually be called on server platform, so we want to be at least ready for that.

// in browser platform
const script = document.createElement('script');
script.type = 'text/javascript';
script.defer = true;
script.src = 'locale/ar-JO.js';
document.head.appendChild(script);

// in server platform, we'll add this later
// require('./ar-JO.js');
Enter fullscreen mode Exit fullscreen mode

Let's do one refactor before we jump into serving the files. Create a single JavaScript key, and namespace it, so that the 10xers don't troll us, not that it matters.

// the locales/language.js file

const keys = {
  NoRes: '',
  // ...
};
// combine and namespace
// window will later be global
window.cr = window.cr || {};
window.cr.resources = {
  language: 'en',
  keys,
  localeId: 'en-US'
};
Enter fullscreen mode Exit fullscreen mode

cr is short for cricket. Our project code name.

In our res class:

// in res class remove imported keys from /locales/language.ts

declare const cr: {
  resources: {
    keys: any;
    language: string;
    localeId: string;
  };
};

export class Res {
  // to use in app.module
  public static get LocaleId(): string {
    return cr?.resources.localeId;
  }

  // add a private getter for keys
  private static get keys(): any {
    return cr?.resources.keys;
  }
  // use it like this this
  public static Get(key: string, fallback?: string): string {
    const keys = Res.keys;
    // ...
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Language specific files

We shall now create two files in locale folder ready to be shipped: cr-en, and cr-ar. The cr-ar contains the added ar-JO locale script, while the cr-en has nothing special. We prefix not to clash with Angular packages, since ar.js and en.js already exist.

(the en-AE mentioned below is for example only, we are not going to use it.)

We are building now with the following angular.json settings:

"projects": {
    "cr": {
      "architect": {
        "build": {
          "options": {
            "resourcesOutputPath": "assets/",
            "index": "src/index.html",
            "assets": [
              // ...
              // add all locales in dev
              {
                "glob": "*.js",
                "input": "src/locale",
                "output": "/locale"
              },
              {
                // add angular packages in dev, be selective
                // en-AE is an example
                "glob": "*(ar-JO|en-AE).js",
                "input": "node_modules/@angular/common/locales/global",
                "output": "/locale"
              }
            ]
          },
          "configurations": {
            "production": {
              // place in client folder
              "outputPath": "./host/client/",
              // ...
              // overwrite assets
              "assets": [
                // add only locales needed
                // names clash with Angular packages, prefix them
                {
                  "glob": "*(cr-en|cr-ar).js",
                  "input": "src/locale",
                  "output": "/locale"
                },
                {
                  // add angular packages needed
                  "glob": "*(ar-JO|en-AE).js",
                  "input": "node_modules/@angular/common/locales/global",
                  "output": "/locale"
                }
              ]
            }
          }
        },
        // server build
        "server": {
          "options": {
            // place in host server
            "outputPath": "./host/server",
            "main": "server.ts"
            // ...
          },
          // ...
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

Let's build.

Browser only application

Starting with the Angular builder:

ng build --configuration=production

This generates the output file host/client. Inside that folder, we have locale folder that contains all javascript files we included in assets:

  • /host/client/locale/cr-en.js
  • /host/client/locale/cr-ar.js
  • /host/client/locale/ar-JO.js

The index file contains a reference for locale/language.js, now it's our job to rewrite that URL to the right language file. Creating multiple index files is by far the most extreme, and the best solution. But today, we'll just rewrite using ExpressJS routing.

In our main server.js, we need to create a middleware to detect language, for now, from a cookie. The cookie name can easily be lost around, so first, I want to create a config file where I will place all my movable parts, this is a personal preference, backend developers probably have a different solution.

// server/config.js
const path = require('path');
const rootPath = path.normalize(__dirname + '/../');

module.exports = {
  env: process.env.Node_ENV || 'local',
  rootPath,
  // we'll use this for cookie name
  langCookieName: 'cr-lang',
  // and this for prefix of the language file
  projectPrefix: 'cr-'
};
Enter fullscreen mode Exit fullscreen mode

The language middleware:

// a middleware to detect language

module.exports = function (config) {
  return function (req, res, next) {
    // check cookies for language, for html request only
    res.locals.lang = req.cookies[config.langCookieName] || 'en';

    // exclude non html sources, for now exclude all resources with extension
    if (req.path.indexOf('.') > 1) {
      next();
      return;
    }

    // set cookie for a year
    res.cookie(config.langCookieName, res.locals.lang, {
      expires: new Date(Date.now() + 31622444360),
    });

    next();
  };
};
Enter fullscreen mode Exit fullscreen mode

This middleware simply detects the language cookie, sets it to response locals property, and then saves the language in cookies.

The basic server:

const express = require('express');

// get the config
const config = require('./server/config');

// express app
const app = express();

// setup express
require('./server/express')(app);

// language middleware
var language = require('./server/language');
app.use(language(config));

// routes
require('./server/routes')(app, config);

const port = process.env.PORT || 1212;
// listen
app.listen(port, function (err) {
  if (err) {
    return;
  }
});
Enter fullscreen mode Exit fullscreen mode

The routes for our application:

// build routes for browser only solution
const express = require('express');

// multilingual, non url driven, client side only
module.exports = function (app, config) {

  // reroute according to lang, don't forget the prefix cr-
  app.get('/locale/language.js', function (req, res) {
    res.sendFile(config.rootPath +
        `client/locale/${config.projectPrefix}${res.locals.lang}.js`
    );
    // let's move the path to config, this becomes
    // res.sendFile(config.getLangPath(res.locals.lang));
  });

  // open up client folder, including index.html
  app.use(express.static(config.rootPath + '/client'));

  // serve index file for all other urls
  app.get('/*', (req, res) => {
    res.sendFile(config.rootPath + `client/index.html`);
  });
};
Enter fullscreen mode Exit fullscreen mode

Running the server, I can see the cookie saved in Chrome Dev tools, changing it, reloading, it works as expected.

Let's move the language path to server config because I will reuse it later.

module.exports = {
  // ...
  getLangPath: function (lang) {
    return `${rootPath}client/locale/${this.projectPrefix}${lang}.js`;
  }
};
Enter fullscreen mode Exit fullscreen mode

Server platform

Going back to a previous article: Loading external configurations in Angular Universal, we isolated the server, and I specifically mentioned one of the benefits is serving a multilingual app using the same build. Today, we shall make use of it. When building for SSR, using:

ng run cr:server:production

The file generated in host/server folder is main.js. The following is the routes done with SSR in mind (in StackBlitz it's host/server/routes-ssr.js)

const express = require('express');

// ngExpressEngine from compiled main.js
const ssr = require('./main');

// setup the routes
module.exports = function (app, config) {
  // set engine, we called it AppEngine in server.ts
  app.engine('html', ssr.AppEngine);
  app.set('view engine', 'html');
  app.set('views', config.rootPath + 'client');

  app.get('/locale/language.js', function (req, res) {
    // reroute according to lang
    res.sendFile(config.getLangPath(res.locals.lang));
  });

  // open up client folder
  app.use(express.static(config.rootPath + '/client', {index: false}));

  app.get('/*', (req, res) => {
    // render our index.html
    res.render(config.rootPath + `client/index.html`, {
      req,
      res
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

Exclude index.html file in the static middleware, in order to force the root URL to pass through the Angular engine.

Previously we used a trick to differentiate between server and browser platforms to include the same JavaScript on both platforms:

// in javascript, an old trick we used to make use of the same script on both platforms
if (window == null){
    exports.cr = cr;
}
Enter fullscreen mode Exit fullscreen mode

Looking at Angular Locale scripts, they are wrapped like this:

// a better trick
(function(global) {
  global.something = 'something';
})(typeof globalThis !== 'undefined' && globalThis || typeof global !== 'undefined' && global ||
   typeof window !== 'undefined' && window);
Enter fullscreen mode Exit fullscreen mode

This is better. Why didn't I think of that earlier? Oh well. Let's rewrite our language files to be wrapped by a function call:

// locale/language.js (cr-en and cr-ar) make it run on both platforms
(function (global) {
  // for other than en
  if (window != null) {
    // in browser platform
    const script = document.createElement('script');
    script.type = 'text/javascript';
    script.defer = true;
    script.src = 'locale/ar-JO.js';
    document.head.appendChild(script);
  } else {
    // in server platform
    require('./ar-JO.js');
  }

  const keys = {
    NoRes: '',
    // ...
  };

  global.cr = global.cr || {};
  global.cr.resources = {
    language: 'ar',
    keys,
    localeId: 'ar-JO',
  };
})(
  (typeof globalThis !== 'undefined' && globalThis) ||
    (typeof global !== 'undefined' && global) ||
    (typeof window !== 'undefined' && window)
);
Enter fullscreen mode Exit fullscreen mode

In language middleware, require the file.

module.exports = function (config) {
  return function (req, res, next) {
    // ... get cookie

    // if ssr is used
    require(config.getLangPath(res.locals.lang));

    // ... save cookie
  };
};
Enter fullscreen mode Exit fullscreen mode

Running the server. We are faced with two problems:

  • app.module is loading immediately, before any routing occurs. It looks for LOCAL_ID in global.cr.resources, which has not been loaded anywhere yet.
  • Defining a default one, the locale does not change on the server, dynamically, since app.module has already run with the first locale.

To dynamically change the LOCALE_ID on the server---without restarting the server, Googled and found a simple answer. Implementing useClass for the provider in app.module. Looking into the code generated via SSR, this change eliminated the direct referencing of LocalId, and turned it into a void 0 statement.

exports.Res = exports.LocaleId = void 0;

This is a recurring problem in SSR, whenever you define root level static elements. Note that once the application hydrates (turns into Browser platform), it no longer matters, browser platform is magic!

// in Res class, extend the String class and override its default toString
export class LocaleId extends String {
    toString() {
        return cr.resources.localeId || 'en-US';
    }
}

// and in app.module, useClass instead of useValue
@NgModule({
  // ...
  providers: [{ provide: LOCALE_ID, useClass: LocaleId }]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

This takes care of the first problem. It also partially takes care of the second one. The new problem we're facing now is:

  • NodeJS requires files once. If required again, the file will be pulled out the cache, and it will not run the function within. Thus on server platform, switching the language works the first time, but switching back to a previously loaded language, will not update the locale.

To fix that, we need to save the different global.cr collections in explicit keys, and in the language middleware assign our NodeJS global.cr.resources to the right collection. In our language JavaScript files, let's add the explicit assignment:

// in cr-en cr-ar, etc,
(function (global) {

  // ...
  // for nodejs, add explicit references
  // global.cr[language] = global.cr.resources
  global.cr.en = global.cr.resources;

})(typeof globalThis !== 'undefined' && globalThis || typeof global !== 'undefined' && global ||
    typeof window !== 'undefined' && window);
Enter fullscreen mode Exit fullscreen mode

In our language middleware, whenever a new language is requested, it is added to the global collection. Then we pull out the one we want:

// language middleware
module.exports = function (config) {
  return function (req, res, next) {
    // ...
    require(config.getLangPath(res.locals.lang));

    // reassign global.cr.resources
    global.cr.resources = global.cr[res.locals.lang];

    // ...
  };
};
Enter fullscreen mode Exit fullscreen mode

Running the server, I get no errors. Browsing with JavaScript disabled, it loads the default language. Changing the cookie in the browser multiple times, it works as expected.

That wasn't so hard was it? Let's move on to URL-based language.

URL-based application

For content-based and public websites, deciding the language by the URL is crucial. To turn our server to capture selected language from URL instead of a cookie, come back next week. 😴

Thanks for reading through another episode. Let me know if I raised an eyebrow.

RESOURCES

RELATED POSTS

Loading external configurations in Angular Universal

Catching and displaying UI errors with toast messages in Angular

Top comments (0)