Sekrab Garage

Single multilingual build

Prerendering in Angular - Part II

Angular September 25, 22
Series:

Angular prerendering

There are many reasons to prerender, and many not to. Each application finds its own use case to prerender, while static site generation is all about prerendering, a huge website like stackoverflow avoids it. Angular recently added universal builder for prerendering, but in this series, I want to investigate other ways.
  1. 3 months ago
    Prerender routes in Express server for Angular - Part I
  2. two months ago
    Prerendering in Angular - Part II
  3. two months ago
    Prerendering in Angular - Part III
  4. two months ago
    Prerendering in Angular - Part IV

Today we will continue with our previous efforts to Replace i18n Package in Angular, and adapt our Express server to generate multiple static pages per language. The plan is:

  1. Adapting to have multilingual support, URL driven
  2. Testing the APP_BASE_URL support
  3. Adapting to make it run for cookie driven apps
  4. Look into Firebase Netlify and Surge support

Multilingual URL driven apps

Planning with the end in mind, we need to create the same routes with different language folders, so /client/projects/index.html needs to sit inside /client/en/projects/index.html. To do that, we need to adapt our fetch module, to loop through all supported languages.

Note: if you have multiple builds (as per the default Angular behavior), the output directory would be swapped. For example: /en/static/projects/index.html
// host/prerender/fetch.js
// example of supported languages which probably should be in a config file
const languages = ['en', 'ar', 'tr'];

// adapt by accepting language
async function renderToHtml(lang, route, port, outFolder) {
  // adapt to call fetch with the language prefixed in URL
  const response = await fetch(`http://localhost:${port}/${lang}/${route}`);
  // ...

  // adapt to place in /client/static/en/route/index.html
    const d = `${outFolder}${lang}/${route}`;
	// ...
}

module.exports = async (port) => {
	// ...
  // adapt to loop through languages
  for (const lang of languages) {
    for (const route of routes) {
      await renderToHtml(lang, route, port, prerenderOut);
    }
  }
};

The Express rule we had does not need to change, as long as our base href value is set to the right language, which is the behavior expected with URL driven apps.

module.exports = function (app) {
	// this rule holds, as long as the base href is set to the right language folder
	// for example "/en/"
  app.use('/', express.static(rootPath + 'client/static'));
	// ... other routes
}

Run the server, browse to http://localhost:port/en/projects, disable JavaScript, and test. You should be seeing the static version of the file.

The fetch script is on StackBlitz.

APP_BASE_HREF token

This is exactly like URL driven applications, with the exception that the base href is set to root /. No adaptations needed. Read about this solution in Using Angular APP_BASE_HREF.

Cookie driven apps

Single build apps based on a cookie value are not recommended. Nevertheless, let’s dig into it. Two things need to be adapted:

  • set the cookie ahead of the prerendering fetch (not allowed)
  • check for physical file before serving our site

In fetch, we are not allowed to set the cookie header, but we can resolve to setting a different header. Then in language middleware we check for that header value along with other factors.

// prerender/fetch.js
// adapt the function to set custom header 
async function renderToHtml(lang, route, port, config) {
	const response = await fetch(`http://localhost:${port}/${route}`, 
		{
			headers: {'cr-lang': lang}
		});

	// ... the rest, save in different language folder as usual
	// ... the output index files all have base href set to '/'
}

We just need to catch the header in the middleware to know which index to serve

// language.js middleware, adapt the value to read "custom header" 
// before resolving to cookie then to default
res.locals.lang = req.get('cr-lang') || req.cookies['cr-lang'] || 'en';

The above with the language loop generates static files inside language folders, like in URL driven apps, with the exception that the generated base href, is set to root /. Now to serve these files, the Express route needs to physically match them, this can be done with a middleware (like serve-static middleware), but here we’ll keep it simple:

// express routes for cookie driven apps
// ...
const { existsSync } = require('fs');

app.get('/*', (req, res) => {
    // first find the physical page, relative to __dirname and routes file
		// res.locals.lang is assumed to be set by middleware cookie reader
		// strip out the URL params if used in Angular ";param=something"
	   
    const static =  rootPath + 'client/static/' + res.locals.lang + req.path.split(';')[0] + '/index.html';
    if (existsSync(static)) {
      res.sendFile(static);
      return;
    }
		// then render Angular SSR (a previous article)
    res.render(rootPath + `index/index.${res.locals.lang}.html`, {
      req,
      res
    });

});

Unfortunately, in StackBlitz I could not set cookies nor detect custom headers to test the prerender function, but I confirm that it works locally.

The cookie based app is on StackBlitz.

Cloud hosting

The main difference in public hosts is the lack of the powerful Express static middleware, but the solution is to simply save the files as they are, under the client (public) folder. So the only difference if you are hosting on Firebase, Netlify and Surge, is having the prerenderOut folder set to client. But, you still have to create an SSR, at least locally, to be able to generate the prerendered files. So there is no way around having a local Express server to run it. Well, there is one way. It would make the effort smaller. An Angular builder that uses RenderModule. This will have to wait till the next episode. 😴

Thanks for reading on, let me know if you spot any marks that need to be sanded.

About serverless functions

If you are hosting your SSR on the cloud under a serverless function, like Firebase, this is as good as an Express server. You can expose your static folder with Express static middleware, and run firebase emulators locally:

firebase emulators:start

then you run a separate express script to simply fetch the already running firebase app (usually at port 5000). Like this:

// simple script /prerender/serverless.js to run while firebase emulators are running
const config = require('../server/config');
const prerender = require('./fetch-url');

async function act(port) {
  await prerender(port, config);
  console.log('Done prerendering');
}

act(5000);

This will not close the port, and it’s easier than trying to run firebase from Node.

cd prerender && node serverless

The code is in StackBlitz.

  1. 3 months ago
    Prerender routes in Express server for Angular - Part I
  2. two months ago
    Prerendering in Angular - Part II
  3. two months ago
    Prerendering in Angular - Part III
  4. two months ago
    Prerendering in Angular - Part IV