Multilingual Angular App hosted on Firebase and Surge with the same build
Ayyash
Posted on August 22, 2022
Last time we figured out a way to prefix all routes without changing the base href
, using APP_BASE_HREF
token in Angular, which helped us host on Netlify a same-build multilingual App. Today we shall try Firebase and Surge.sh.
Hosting on Firebase
I started writing the host configuration for Firebase thinking it had more to offer, but I was disappointed. The hosting configuration does not allow conditions which Netlify allows. We will resolve to a serverless function at one point, which is not provided on the Free plan. Let's dig in.
Setup Firebase locally
To be able to test locally, we need to have at least two files sitting in a host folder, and a global cli.
npm install -g firebase-tools
In StackBlitz find the Firebase dedicated host folder under /host-firebase
The two files are .firebaserc
and firebase.json
// .firebaserc in the root of the firebase host
{
"projects": {
// does not have to exist in the cloud, we are running locally
"default": "cr"
}
}
The firebase.json
we will build up with hosting rules, initially it looks like this:
// firebase.json in the root of the firebase host
{
"hosting": [
{
"target": "web",
// the public folder is where the build will go
"public": "client",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
// TODO
]
}
]
}
To host on Firebase a browser-only app, all files must sit in the same folder, like in Netlify hosting, including any external configuration. On StackBlitz find the example build under /host-firebase
. Our writeindex
task also copies into the same host/client
folder as in Netlify setup (find example in /src/firebase/angular.json
.)
To run locally, change directory into the host
folder and run:
firebase emulators:start
Browse to http://localhost:5000
and I hope you see what I see. Let's begin with the URL based app:
URL driven apps
We face the same issue of differentiating static resources from friendly URLs as in Netlify hosting, but the solution of conditional rules is not available in Firebase, so we use APP_BASE_HREF
right away.
Since all assets are served locally, we just need to rewrite the friendly URLs
// firebase.json for URL based app with APP_BASE_URL set in language script
"rewrites": [
{
"source": "/ar{,/**}",
"destination": "/index.ar.html"
},
// default others
{
"source": "**",
"destination": "/index.en.html"
}
]
The missing opportunity is /
root URL for a second time user. There are some solutions to that, some of them are hacky: making use of index.html
, and others need a little more work: relying on Firebase i18n rewrite.
Make use of the root index.html
Create an empty HTML page with the following script
<!DOCTYPE html>
<html lang="en">
<head>
<script>
const cookie = document.cookie;
let root = 'en';
// use any cookie name, redirect according to value
if (cookie.indexOf('cr-lang') > -1) {
// this line was produced by CoPilot! Not bad!
root = cookie.split('cr-lang=')[1].split(';')[0];
}
// replace URL, a client 301 redirect
window.location.replace(`/${root}`);
</script>
</head>
<body>
</body>
</html>
Don't forget to add this index file to the angular.json
assets to copy over to client
folder.
Use Firebase i18n rewrites
For this to work, the files must be physically saved under en
and ar
folders. So we need to change our Angular builder, or gulp task a bit, to support this folder structure:
|-client/
|----en/
|------index.html
|----ar/
|------index.html
|--assets...
Our firebase.json
then looks like this
// to handle already selected language in cookies
// cookie name must be firebase-language-override
"i18n": {
"root": "/"
},
"rewrites": [
{
"source": "/ar{,/**}",
"destination": "/ar/index.html"
},
{
"source": "**",
"destination": "/en/index.html"
}
]
We can adjust the builder to create this new structure easily, find the new code in StackBlitz under its own builder builder/locales/folders.ts
:
// update builder to build folder structure
interface Options {
// ...
// new property
fileName?: string;
}
export default createBuilder(LocalizeIndex);
function LocalizeIndex(
options: Options,
context: BuilderContext,
): BuilderOutput {
// ...
// instead of writing file, first create folder
if (!existsSync(options.destination + '/' + lang.name)){
mkdirSync(options.destination + '/' + lang.name);
}
// save file with index.html, base href = /
writeFileSync(`${options.destination}/${lang.name}/${ options.fileName || 'index.html'}`, contents);
// ...
return { success: true };
}
In our config (or external configuration), set the cookie name to firebase-language-override
// src/app/config.ts
export const Config = {
Res: {
cookieName: 'firebase-language-override',
//...
},
};
Building, running locally, switching, works. Let's move on to the cookie based solution.
Cookie driven apps
We back up a bit to the normal app, with no APP_BASE_HREF prefix. There are no conditional rewrite rules in Firebase, there is however an i18n solution, and there are serverless functions. Let's try both:
Firebase i18n rewrites:
The following is the sequence of events in a hosting file:
- browse to
/
, the host detects Language from browser and serves/en/index.html
which is a physical file - browsing to
/products
however, in an Angular app, the host will try to serve/en/products/index.html
which does not exist, so it tries to serve/en/404.html
- this
/en/404.html
can have the same contents of the/en/index.html
file and load the Angular app itself, but it is a bad practice since it registers as a404
- We can also create a JavaScript redirect in
404.html
to/
but this too is not a good solution since we would lose the friendly URL
The folder structure would look like this
|-client/
|----en/
|------index.html
|------404.html
|----ar/
|------index.html
|------404.html
|--assets...
That's a reason too many to bog me down, to top it all, the localized 404.html
does not run locally in the emulator, so I could not test it. Moving on.
Firebase serverless function
The Spark "free" plan does not run functions in Firebase, the first paid plan: Blaze however; is quite generous in its free tier. You can try this locally, but to deploy it, you need a Blaze subscription.
To run functions locally, without having a real application, you need to do the following:
Create
functions
folder inside the firebasehost
folder (find it in StackBlitz underhost-firebase
)-
Create a
functions/package.json
// package.json inside functions need nothing else { // that's the only dependency you need to install "dependencies": { "firebase-functions": "^3.22.0" }, // add this, or in firebase.json add runtime, see below "engines": { "node": "16" } }
Add
engines
node topackage.json
as above, following documentation of firebase:
Theย
package.json
file created during initialization contains an important key:ย"engines": {"node": "10"}
. This specifies your Node.js version for writing and deploying functions. You canย select other supported versions.
Or following an error message in my command line, you can create it in firebase.json
:
// firebase.json in root of host, undocumented
{
"hosting": ...
"functions": {
"runtime": "nodejs16"
}
}
- Install the only dependency you need inside the functions folder
firebase-functions
- In
host
, runfirebase emulators:start
- Ignore funny warning messages and browse normally
The function we want to create is a catch-all to send the right index.[lang].html
according to a cookie value:
// functions/index.js
const functions = require('firebase-functions');
exports.serveLocalized = functions.https.onRequest((req, res) => {
// find cr-lang cookie
// Pssst: you can require a config.json
// if you use external configuration to read the cookie name
const cookie = req.headers.cookie;
let lang = 'en';
if (cookie.indexOf('cr-lang') > -1) {
// CoPilot!
lang = cookie.split('cr-lang=')[1].split(';')[0];
}
// serve the right index file
res.status(200).sendFile(`index.${lang}.html`, {root: '../client/'});
});
Under hosting
in firebase.json
// ...
"rewrites": [
{
"source": "**",
"function": "serveLocalized"
}
]
Now run emulator, and browse to http://locahost:5000
The rule in Firebase hosting is: if the file physically exists it shall be served first. So assets are statically served.
We previously created external configuration in Angular, one of the benefits for this project is now we can have the same build hosted by different hosts, each has its own configuration. We can also access this configuration in Firebase functions with a simple
require
statement.
Testing, switching languages in cookies, works. Moving on.
Hosting on Surge
When I decided to try and host it on the Free subscription of Surge, I had little hope, because the ROUTE file is not supported. But I was pleasantly surprised. Surge.sh has a publishing tool. To use, make sure you have installed surge globally:
npm install surge -g
Also, the CNAME
file should be in the published folder, we shall include the file in our assets
array in angular.json
.
To publish, we move into our host
folder, and run surge ./client
(assuming we build into client
sub folder).
Find the example host under StackBlitz /host-surge
folder.
The only available option
We do not have many options:
- Cookie based apps need conditional rewrite, which is not supported
- URL based with
base href
set to a specific language, need to rewrite assets, which also is not an option - With
APP_BASE_URL
, the served file isindex.html
, and if not found,200.html
on root, but in either case, we would not know whichindex.[lang].html
to serve
That leaves us with one option:
- Use
APP_BASE_HREF
- Create physical language folders, for each language.
|-client/
|----en/
|------200.html
|----ar/
|------200.html
|--assets...
- With our Angular builder (or Gulp task)
writeindex
, generate200.html
in each language folder, which will handle rewriting/en/products/...
to/en/200.html
. - Instead of making
index.html
the default Angular app (which works fine), we can make it redirect via JavaScript to the correct sub folder according to a cookie.
<!DOCTYPE html>
<html lang="en">
<head>
<script>
const cookie = document.cookie;
let root = 'en';
// use any cookie name, redirect according to value
if (cookie.indexOf('cr-lang') > -1) {
root = cookie.split('cr-lang=')[1].split(';')[0];
}
// replace URL, a client 301 redirect
window.location.replace(`/${root}`);
</script>
</head>
<body>
</body>
</html>
Surging... running, redirecting, switching language, celebrating. ๐๐ผ๐๐ผ
I'm actually impressed and pleased with Surge
Conclusion
The last bit of the puzzle to twist Angular localization, is extracting the translatable keys into the right JavaScript file. To do that, and to conclude our series, come back next week. ๐ด
RESOURCES
- StackBlitz project
- Firebase Hosting Configuration
- Firebase i18n rewrites
- Test Firebase locally
- Surge Hosting Thank you for reading yet another long article, did you spot the squiggles under the rock?
RELATED POSTS
Posted on August 22, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.