Prerequisites#
You don’t need to be an Angular expert to take this course. However, it would be helpful if you have an understanding of the following concepts:
- Angular components
- Angular services and Dependency Injection
- Angular routing and route resolvers
- End-to-end testing and unit testing techniques for Angular applications
- JavaScript
- TypeScript
- Node.js
Although not essential, knowledge about the following concepts would help you understand some of the techniques applied in the course:
- Angular HTTP interceptors
- Redis
- NoSQL databases (MongoDB)
- RESTful HTTP APIs
You’ll need the following to complete tasks in this course (in parenthess you can find information about software version used during creation of this course):
- Node.js and npm (12.18.2 and 7.0.3)
- Angular CLI (10.2.0)
- Postman - you will use it to perform REST request, if you wish you can also use cURL instead
You can take this course both offline and online. However, I strongly recommend you to learn when connected to the Internet. This will enable you to publish your application and test it in the battlefield. To complete every task online, you will also need the following:
- A Heroku account
- A MongoDB Cloud account (or an account with any other publicly available MongoDB provider)
The following is not necessary, but nice to have:
- A Cloudflare account
- An account with a domain registrar with access to DNS records (you will need this to put your application on the Cloudflare CDN)
If you will run into troubles during the course, you can fallback to the downloadable projects that are provided at the beginning and end of each module. These projects contains all the code, that you should have at the given stage of the course.
Offline#
If you want to follow this course offline, make sure that you have installed the following software:
- MongoDB server
- Redis server
You also should setup your project and install necessary dependencies, before you will go offline:
ng new my-universal-app --style scss --routing true --skipTests
cd my-universal-app
ng add @nguniversal/express-engine
npm i express mongodb cors body-parser cookie-parser saslprep@1.0.0 redis compression
npm -D sinon concurrently mongo-unit
Apart of that, setup the side-project that will be used in this course:
cd ..
mkdir my-service
cd my-service
git init
npx gitignore node
npm init -y
npm install express body-parser cors
touch index.js
Moreover you should also download:
See you in the next module!
What you will build#
In this course module, you will build a production-ready Angular application. It will consist of a list of products with pictures, descriptions, and prices. Each product will have a dedicated landing page. You will also develop the functionality of adding products to a list of favorites and displaying that list.
You will build a back-end and API to feed your application with data. The data will be retrieved from MongoDB provisioned in cloud.mongodb.com.
The application flow#
Services and components in your Angular application will need to be composed to communicate as shown in the diagram below (solid purple lines represents REST calls; dotted lines represents in-app navigation):
- When the user navigates to the application URL, their browser will be served by Heroku with HTML and JavaScript that make up the Angular application.
- From now on, communication will be performed via REST calls (purple arrows) - that’s how Angular is going to feed the application with the data retrieved from MongoDB.
- Inside the application, the user will able to navigate within the following routes:
-
/ and /products (shown in the diagram): the list of products displayed by
ProductsListComponent
(blue rectangle) andProductDetailsComponent
(green rectangle). -
/product/:id : product landing page containing a long description of the product - displayed by
ProductDetailsComponent
(green rectangle). -
/favorites (shown in the diagram): the list of products that have been marked as favorite by the user, displayed with
FavoritesComponent
(orange rectangle) andProductDetailsComponent
(green rectangle).AuthGuard
(red rounded rectangle) will protect this route from unauthorized access. - Components will utilize two services which will retrieve data via REST calls:
-
ProductsService
(light-blue rounded rectangle) - responsible for getting data about all products, or about a specific product. -
UserService
(yellow rounded rectangle) - responsible for user authentication, getting data about the user (i.e. the list of favorite products) and adding a given product to the favorites list.
What you will learn#
After finishing this module, you will know how to:
- Set up an Angular project
- Set up an ExpressJS back-end
- Retrieve data from MongoDB using NodeJS
- Set up and retrieve cookies
- Encrypt and decrypt cookies
- Deploy the application to Heroku
Generate the project and components#
First of all, you need to generate a new project and its components using the Angular CLI ( @angular/cli
). Type the following commands in your terminal:
ng new my-universal-app --style scss --routing true --skipTests
cd my-universal-app
ng g c header --skipTests --inlineStyle --inlineTemplate
ng g c products-list --skipTests --inlineStyle --inlineTemplate
Build the application skeleton#
You’re going to use the Bootstrap CSS framework. This means you need to import the CSS available on Bootstrap CDN into your application.
In your src/index.html file, add a link
element with a reference to the stylesheet hosted on the CDN:
<link rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
crossorigin="anonymous">
And noscript
section:
<noscript>
Unfortunately, your browser doesn't support JavaScript
that is necessary to run this application.
</noscript>
Set up basic CSS in the src/styles.scss file:
html {
height: 100%;
width: 100%;
}
body {
height: 100%;
width: 100%;
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,
"Segoe UI",Roboto,"Helvetica Neue",Arial,
"Noto Sans",sans-serif;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
noscript {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
Routing#
Next, let’s declare application routing. Add the following routes to src/app/app-routing.module.ts :
import { ProductsListComponent } from './products-list/products-list.component';
const routes: Routes = [
{ path: '', redirectTo: 'products', pathMatch: 'full' },
{ path: 'products', component: ProductsListComponent },
];
Header#
It’s time to build the application header. You don’t need any business logic there, so simply add the following HTML template to src/app/header/header.component.ts :
template: `
<div class="navbar navbar-dark bg-dark shadow-sm">
<div class="container d-flex justify-content-between">
<a
routerLink="/"
class="navbar-brand d-flex align-items-center"
><strong>Welcome to the (work)shop!</strong></a
>
</div>
</div>
`,
Application component#
Let’s prepare a template for AppComponent
. That’s the place where you are going to have your <router-outlet>
and website footer. Replace the code in src/app/app.component.html with the following:
<app-header></app-header>
<div class="content bg-light">
<div class="album py-5 bg-light">
<div class="container">
<router-outlet></router-outlet>
</div>
</div>
</div>
<footer class="text-muted">
<div class="container">
<p><a
href="https://www.newline.co/courses/newline-guide-to-angular-universal">
The newline Guide to Angular Universal
</a></p>
</div>
</footer>
To make your footer sticky, add the following styles to src/app/app.component.scss :
:host {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.content {
flex: 1;
}
footer {
padding-top: 3rem;
padding-bottom: 3rem;
}
Test what you have built so far by running the Angular JIT compiler. Type the following command in your terminal:
ng serve
After navigating to http://localhost:4200, you should see the following:
Building the Backend#
Now implement the backend which will be used to host your Angular application on production. Add the necessary dependency to your Angular project:
npm i express
Create a new file called backend.ts in your project root directory (next to the package.json file) and add the following code to it:
import * as express from 'express';
import { join } from 'path';
export const app = express();
const distFolder = join(
process.cwd(),
'dist/my-universal-app'
);
app.get(
'*.*',
express.static(distFolder, {
maxAge: '1y',
})
);
app.use('/', express.static(distFolder));
app.use('/**', express.static(distFolder));
const port = process.env.PORT || 80;
app.listen(port, () => {
console.log(
`Backend is runing on: http://localhost:${port}`
);
});
The code above serves the business logic from api.ts under the /api/* endpoints (you will add it soon).
The code above serves all traffic with static files available in the dist folder where your Angular application will reside.
Compiler and package.json scripts#
The final step is to set up compiler options for the backend. Create a new file called tsconfig.backend.json and add the following configuration to it:
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"outDir": "./dist/backend",
"module": "commonjs",
"types": [
"node"
]
},
"files": [
"backend.ts"
]
}
Last but not least, adjust the scripts
section in the package.json file by changing the start
script and adding the build:backend
script:
"scripts": {
"ng": "ng",
"start": "node dist/backend/backend.js",
"build": "ng build --prod && npm run build:backend",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"build:backend": "tsc --project tsconfig.backend.json"
},
Testing the backend#
Build and run the backend using the following commands:
npm run build
npm start
Navigate with your browser to http://localhost. You should see your Angular application skeleton:
Setting up MongoDB#
Before you start coding the backend, you need a MongoDB instance where you will keep your application data.
There are a few ways to do this. You can either 1. install MongoDB on your local machine and test it that way or 2. use a cloud MongoDB instance.
In both cases, you will also need to fed your database with data. If you wish, you can use the same objects as I used. To do that, download the db_dump - you will import it into your DB during this lesson.
Setting up MongoDB locally#
Below, we’re going to describe how to use a cloud MongoDB instance, but if you’d rather use a local installation, then the MongoDB docs are very good. For your convenience here are the relevant links, depending on your system:
- Install MongoDB Community Edition on macOS
- Install MongoDB Community Edition on Windows
- Install MongoDB Community Edition on Linux
Because local development environments vary so widely, we can’t describe each scenario in detail. But if you follow the above instructions and run into trouble, join our Discord server and someone can probably help you out.
Provisioning a MongoDB Atlas cloud instance#
Once your application is ready, navigate to https://cloud.mongodb.com/user and sign in to your account. Then create a new cluster in a region that is closest to your default Heroku region:
When your cluster is ready, click the Connect button and follow the prompts in the Connect to Cluster panel to:
- Allow access from anywhere
- Create a MongoDB user
When creating your password, avoid using characters that will have to be URL-encoded when entered on your system’s command line. Be sure to save your MongoDB username and password in a safe place.
In the Choose a connection method step, select Connect with the Mongo Shell . Follow directions for downloading, installing, and configuring the MongoDB Shell to run on your system.
When you’ve configured the MongoDB Shell to run on your system, copy the supplied command-line instructions to a safe place.
Feeding the database#
Your database will consist of two collections: products
and users
. Inside the products
collection you’ll keep objects that conform to the following interface:
{
_id: ObjectID,
name: string,
price: float,
description: string,
image: string
}
While entries in users
collection will conform the following interface:
{
"_id": ObjectID,
"email": string,
"password": string,
"favorite": string[]
}
That’s the moment when you can use the dump downloaded at the beginning of the lesson. If you didn’t do that, it’s available for download here. Once you will unzip the archive, you should have two files:
- products.json
- users.json
Now you can use this files with the mongoimport
command:
mongoimport --uri mongodb+srv://<username>:<password>@<hostname>/<database_name> \
--collection products --file products.json
For example:
mongoimport \
--uri mongodb+srv://angular-universal:abcd1234@cluster0.bcfzj.mongodb.net/cluster0 \
--collection products --file products.json
To import the users
collection, use the following command:
mongoimport --uri mongodb+srv://<username>:<password>@<hostname>/<database_name> \
--collection users --file users.json
Reading from the database#
At this moment, you might want to see what data do you have imported.
Connect with your MongoDB using the following command:
mongo "mongodb+srv://<hostname>/<database_name>" --username <username>
For example:
mongo "mongodb+srv://cluster0.bcfzj.mongodb.net/cluster0" --username angular-universal
Once your connection is established, ensure that both collections are in place:
show collections
Use the find
command to verify if collections contain imported data:
db.users.find()
db.products.find()
When you’re done use the exit
command to close connection.
You can also see the database content in your Cloud MongoDB account. To do that navigate into your account, and click on the collections button:
Building the API#
Once your MongoDB instance is set up and fed with data, it’s time to build an API that retrieves that data from the DB and serves it to the API consumer. Install the necessary dependencies:
npm i mongodb cors body-parser saslprep@1.0.0
Creating API - products endpoints#
Create a new file called api.ts (in the same directory as backend.ts ) and add the following code:
import * as express from 'express';
import * as cors from 'cors';
import * as bodyParser from 'body-parser';
export const api = express.Router();
api.use(
cors({
origin: true,
credentials: true,
})
);
api.use(bodyParser.json());
The code above introduces the express.Routing()
object and sets up the middleware that you are going to use.
Connecting to the database#
To respond to an API client with a product list or user data, you first need to retrieve it from MongoDB. It’s time to instantiate a connection with your MongoDB instance.
Add the following import statements to api.ts :
import { ObjectId, MongoClient } from 'mongodb';
Set up a connection with the database:
const dbUrl =
'mongodb+srv://angular-universal:abcd1234@cluster0.bcfzj.mongodb.net';
const dbClient = MongoClient.connect(dbUrl, {
useUnifiedTopology: true,
}).then((connection) => connection.db('cluster0'));
dbClient.then(() => {
console.log('Connected to the database.');
});
Remember to replace the connection string and database name with your database-specific data.
Beware that database name which you provide in the connection.db()
method must match database name in your Atlasian account. Otherwise your program will fail silently - instead of returning data your API will respond with empty arrays and objects.
Because you are going to retrieve data from the database in multiple places in code, it’s a good idea to add a helper function:
async function retrieveFromDb(
collectionName,
project = {},
query = {}
): Promise<any[]> {
project['_id'] = 0;
const db = await dbClient;
return new Promise((resolve) => {
db.collection(collectionName)
.aggregate([
{ $match: query },
{
$addFields: {
id: { $toString: '$_id' },
},
},
{ $project: project },
])
.toArray((err, objects) => {
resolve(objects);
});
});
}
The above code introduces the retrieveFromDb() function. It not only retrieves data from the database but also rewrites object IDs from the ObjectId type to the id field of type string. This would eventually make it easier to compare data by ID. (You won’t need to remember to use the toString() method every time you want to compare an ID retrieved via request to an ID retrieved from the database.)
Retrieving products
Now you can respond to requests for products. Add the following code to api.ts:
api.get('/products', async (req, res) => {
console.log('GET products');
const products = await retrieveFromDb('products', {
description: 0,
});
res.send(products);
});
api.get('/products/:id', async (req, res) => {
const products = await retrieveFromDb(
'products',
{},
{ _id: ObjectId(req.params.id) }
);
if (products.length == 0) {
res.sendStatus(404);
} else {
res.send(products[0]);
}
});
You’ve just implemented two endpoints:
GET /products responds with a list of all products (without product descriptions).
GET /products/:id responds with the full description of the product specified by the :id query parameter.
Finally, you need to instruct the back-end how to serve your API. Import your API in the backend.ts file:
import { api } from './api';
And add the following line, before the app.get(’.’) statement:
app.use('/api', api);
After those modifications your backend.ts should look like the following:
import { api } from './api';
import * as express from 'express';
import { join } from 'path';
export const app = express();
app.use('/api', api);
const distFolder = join(
process.cwd(),
'dist/my-universal-app'
);
app.get(
'*.*',
express.static(distFolder, {
maxAge: '1y',
})
);
app.use('/', express.static(distFolder));
app.use('/**', express.static(distFolder));
const port = process.env.PORT || 80;
app.listen(port, () => {
console.log(
`Backend is runing on: http://localhost:${port}`
);
});
Testing the endpoints
Build and run the backend using the following commands:
npm run build:backend
npm start
You can use Postman to verify that the API works as expected:
You can also verify if API works as expecte by performing the following curl commands in separate terminal window:
curl http://localhost/api/products
curl http://localhost/api/products/5ed3bbefaf1c4c0e81d9b406
Building the API - user interaction
You are about to implement three more endpoints:
POST /login will accept body in the form of {email: user_email, password: user_password}, look for a user in the database, and respond with one of the following:
Status code 200 along with a cookie containing userID if the user is found.
Status code 401 if the user is not found.
GET /isLoggedIn will respond with one of the following:
Status code 200 if a cookie containing userID is present in the request, and the user with the given userID exists in the database.
Status code 401 if the cookie is not set or the user is not found.
POST /favorites/:id will add a product to the list of a user’s favorites. The product will be defined by the :id query parameter, and the user will be retrieved by userID from the cookie. Action success will be confirmed by status code 202. If the cookie is not present, this endpoint will respond with status code 401.
You will keep userID in a cookie file, which could be easily compromised. An attacker could steal this data, use it to impersonate someone else, and act in the system as that person, which is as easy as changing the cookie file content. To protect your users against this kind of attacks, always encrypt cookies containing sensitive data.
Install dependencies necessary to accomplish this step:
npm i cookie-parser
Cookie encryption
Generate a private key that you will use to encrypt and decrypt cookies:
openssl genrsa -out ./privkey.pem 2048
Add import statements that you’ll need for /user/* endpoints to api.ts:
import * as crypto from 'crypto';
import * as fs from 'fs';
import { join } from 'path';
Declare the encrypt() and decrypt() functions that will help protect data kept in cookies from manipulation:
const key = fs.readFileSync(
join(process.cwd(), 'privkey.pem'),
'utf8'
);
function encrypt(toEncrypt: string): string {
const buffer = Buffer.from(toEncrypt);
const encrypted = crypto.privateEncrypt(key, buffer);
return encrypted.toString('base64');
}
function decrypt(toDecrypt: string): string {
const buffer = Buffer.from(toDecrypt, 'base64');
const decrypted = crypto.publicDecrypt(key, buffer);
return decrypted.toString('utf8');
}
User authentication
You can now introduce endpoints mentioned at the beginning of this section, and keep userID in the cookie file as it’s now protected from manipulation.
Start by importing the cookie-parser:
import * as cookieParser from 'cookie-parser';
Add it to your api sub-application:
api.use(cookieParser());
Then add the endpoints code:
const hash = crypto.createHash('sha512');
api.post('/login', async (req, res) => {
const email = req.body.email;
const password = hash
.update(req.body.password)
.digest('hex');
const foundUsers = await retrieveFromDb(
'users',
{ password: 0 },
{ email: email, password: password }
);
if (foundUsers.length == 0) {
res.sendStatus(401);
} else {
const user = foundUsers[0];
res.cookie('loggedIn', encrypt(`${user.id}`), {
maxAge: 600 * 1000,
httpOnly: true,
});
delete user.id;
res.send(user);
}
});
Notice the usage of the crypto library in the login endpoint. Passwords are stored in the database in hashed form (using SHA-512), and when the user sends their password, it needs to be hashed again before it’s used to query the database.
Never store user passwords in plain text. If your database is compromised and data you keep in it leaks, it will be a disaster for your customers if you store their passwords in plain text.
You should also consider using salt in hashed passwords when you’re building production-ready applications. In this course, for the sake of simplicity, we skip using salt.
Now introduce the /isLoggedIn endpoint:
api.get('/isLoggedIn', async (req, res) => {
if (req.cookies.loggedIn) {
const userId = decrypt(req.cookies.loggedIn);
const foundUsers = await retrieveFromDb(
'users',
{ _id: 0, password: 0 },
{ _id: ObjectId(userId) }
);
const user = foundUsers[0];
delete user.id;
res.send(user);
} else {
res.sendStatus(401);
}
});
Now introduce the /isLoggedIn endpoint:
api.get('/isLoggedIn', async (req, res) => {
if (req.cookies.loggedIn) {
const userId = decrypt(req.cookies.loggedIn);
const foundUsers = await retrieveFromDb(
'users',
{ _id: 0, password: 0 },
{ _id: ObjectId(userId) }
);
const user = foundUsers[0];
delete user.id;
res.send(user);
} else {
res.sendStatus(401);
}
});
To verify if those endpoints works as expected, rebuild back-end and perform the following cURL commands:
curl -i http://localhost/api/isLoggedIn
curl -i --header "Content-Type: application/json" \
--request POST \
--data '{"email": "name@email.com","password": "abc123"}' \
--cookie-jar cookie.txt \
http://localhost/api/login
curl -i --cookie cookie.txt \
http://localhost/api/isLoggedIn
First request should receive the 401 status response, as user is not authenticated. In second request you perform the login action - response should have status 200 and contain information about the user. At the very end you verify if user is recognized as authenticated when the proper cookie is sent with the request.
You may ofcourse use Postman to perform same requests and verify if your API works. Postman would persist the cookie, so there is no need to save it in file and attach in further requests.
Adding product to list of favorites
Last endpoint which needs to be implemented is favorites/:id. Add it at the very end of the api.ts file:
api.post('/favorites/:id', async (req, res) => {
const userId = decrypt(req.cookies.loggedIn);
const newFavorite = req.params.id;
const user = await retrieveFromDb(
'users',
{ _id: 0, password: 0 },
{ _id: ObjectId(userId) }
);
const currentFavorites = user[0].favorite;
if (!currentFavorites.includes(newFavorite)) {
currentFavorites.push(newFavorite);
await (await dbClient)
.collection('users')
.updateOne(
{ _id: ObjectId(userId) },
{ $set: { favorite: currentFavorites } }
);
res.status(202);
}
delete user[0].id;
res.send(user[0]);
});
To verify if user is able to add products to favorites, rebuild back-end and perform the following cURL commands:
curl -i --header "Content-Type: application/json" \
--request POST \
--data '{"email": "name@email.com","password": "abc123"}' \
--cookie-jar cookie.txt \
http://localhost/api/login
curl -i \
--request POST \
--cookie cookie.txt \
http://localhost/api/favorites/5ed3bbefaf1c4c0e81d9b406
As the response for the second reqeust, you should see user with updated favorite list:
Final solution
You’ve just made a few changes in the api.ts file, and this is what it should look like after applying the steps above:
import * as express from 'express';
import * as cors from 'cors';
import * as bodyParser from 'body-parser';
import * as cookieParser from 'cookie-parser';
import { ObjectId, MongoClient } from 'mongodb';
import * as crypto from 'crypto';
import * as fs from 'fs';
import { join } from 'path';
export const api = express.Router();
api.use(
cors({
origin: true,
credentials: true,
})
);
api.use(bodyParser.json());
api.use(cookieParser());
const dbUrl =
'mongodb+srv://angular-universal:abcd1234@cluster0.bcfzj.mongodb.net';
const dbClient = MongoClient.connect(dbUrl, {
useUnifiedTopology: true,
}).then((connection) => connection.db('cluster0'));
dbClient.then(() => {
console.log('Connected to the database.');
});
async function retrieveFromDb(
collectionName,
project = {},
query = {}
): Promise<any[]> {
project['_id'] = 0;
const db = await dbClient;
return new Promise((resolve) => {
db.collection(collectionName)
.aggregate([
{ $match: query },
{
$addFields: {
id: { $toString: '$_id' },
},
},
{ $project: project },
])
.toArray((err, objects) => {
resolve(objects);
});
});
}
api.get('/products', async (req, res) => {
console.log('GET products');
const products = await retrieveFromDb('products', {
description: 0,
});
res.send(products);
});
api.get('/products/:id', async (req, res) => {
const products = await retrieveFromDb(
'products',
{},
{ _id: ObjectId(req.params.id) }
);
if (products.length == 0) {
res.sendStatus(404);
} else {
res.send(products[0]);
}
});
const key = fs.readFileSync(
join(process.cwd(), 'privkey.pem'),
'utf8'
);
function encrypt(toEncrypt: string): string {
const buffer = Buffer.from(toEncrypt);
const encrypted = crypto.privateEncrypt(key, buffer);
return encrypted.toString('base64');
}
function decrypt(toDecrypt: string): string {
const buffer = Buffer.from(toDecrypt, 'base64');
const decrypted = crypto.publicDecrypt(key, buffer);
return decrypted.toString('utf8');
}
const hash = crypto.createHash('sha512');
api.post('/login', async (req, res) => {
const email = req.body.email;
const password = hash
.update(req.body.password)
.digest('hex');
const foundUsers = await retrieveFromDb(
'users',
{ password: 0 },
{ email: email, password: password }
);
if (foundUsers.length == 0) {
res.sendStatus(401);
} else {
const user = foundUsers[0];
res.cookie('loggedIn', encrypt(`${user.id}`), {
maxAge: 600 * 1000,
httpOnly: true,
});
delete user.id;
res.send(user);
}
});
api.get('/isLoggedIn', async (req, res) => {
if (req.cookies.loggedIn) {
const userId = decrypt(req.cookies.loggedIn);
const foundUsers = await retrieveFromDb(
'users',
{ _id: 0, password: 0 },
{ _id: ObjectId(userId) }
);
const user = foundUsers[0];
delete user.id;
res.send(user);
} else {
res.sendStatus(401);
}
});
api.post('/favorites/:id', async (req, res) => {
const userId = decrypt(req.cookies.loggedIn);
const newFavorite = req.params.id;
const user = await retrieveFromDb(
'users',
{ _id: 0, password: 0 },
{ _id: ObjectId(userId) }
);
const currentFavorites = user[0].favorite;
if (!currentFavorites.includes(newFavorite)) {
currentFavorites.push(newFavorite);
await (await dbClient)
.collection('users')
.updateOne(
{ _id: ObjectId(userId) },
{ $set: { favorite: currentFavorites } }
);
res.status(202);
}
delete user[0].id;
res.send(user[0]);
});
User Authentication
Once the application’s backend and the API are ready, it’s time to go forward and build the frontend Angular application.
To be able to retrieve data using the API, remember to keep your backend built and running:
npm start
You are now about to implement the user authentication flow:
-
The FavoritesComponent component will display the user’s favorite products.
-
The AuthGuard service will protect FavoritesComponent from unauthorized access.
-
In case of unauthorized access, the user will be redirected to LoginComponent.
To generate these components and services, type the following commands in a new terminal window:
ng g s user --skipTests
ng g s auth-guard --skipTests
ng g c login --skipTests --inlineStyle --inlineTemplate
ng g c favorites --skipTests --inlineStyle --inlineTemplate
Adjusting AppModule
The code you are about to implement will depend on two modules that AppModule does not import by default: HttpClientModule and ReactiveFormsModule.
To fix this, add two import statements to src/app/app.module.ts:
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
Then update the imports table:
imports: [
BrowserModule,
AppRoutingModule,
HttpClientModule,
ReactiveFormsModule,
],
User service
Before you start coding UserService, you need to implement the User model that it will be using.
Create a new folder, src/model/. Inside this folder, add a new file, user.model.ts, with the following code:
export interface User {
email: string;
favorite: string[];
}
Now you can start implementing UserService. Start with declaring class fields, injecting services that you’ll need, and checking if the user is authenticated. Add the following code to src/app/user.service.ts:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
map,
catchError,
tap,
distinctUntilChanged,
mergeMap,
filter,
take,
} from 'rxjs/operators';
import {
Observable,
of,
Subject,
BehaviorSubject,
} from 'rxjs';
import { User } from '../model/user.model';
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class UserService {
private API_URL = 'http://localhost/api';
private currentUser$: Subject<User> = new BehaviorSubject(
null
);
private redirectUrl: string = '/';
private redirectParams: any = null;
constructor(
private http: HttpClient,
private router: Router
) {
this.currentUser$
.pipe(
distinctUntilChanged(),
filter((user) => !user),
mergeMap(() => this.checkCookie())
)
.subscribe();
}
private checkCookie(): Observable<void> {
return this.http
.get(`${this.API_URL}/isLoggedIn`, {
withCredentials: true,
})
.pipe(
catchError(() => of(null)),
tap((user) => this.currentUser$.next(user))
);
}
The code above introduces four variables:
-
API_URL is the URL of the API that the service is going to communicate with.
-
currentUser$ is an Observable representing the currently authenticated user, or null if there is no authenticated user.
-
redirectUrl is a placeholder that AuthGuard uses to define where the user should be redirected after successful authentication.
-
redirectParams represents path params of the URL above.
The constructor injects two services:
-
HttpClient is used to perform HTTP calls to the API.
-
Router is used to redirect the user to the desired URL after successful authentication.
Within the constructor, you’ve piped the currentUser$ observable with a set of operators that includes a call to the checkCookie() function whenever the value of currentUser$ changes to null.
Notice that you’ve called the subscribe() method of the currentUser$ object. You should be aware of keeping opened subscriptions in your code, because doing this may lead to memory leaks.
Keeping a subscription open is only acceptable here because we’re dealing with a service. Thanks to the Dependency Injection mechanism, you are guaranteed that you will only have one instance of this class (UserService) in your application.
As to components, you should avoid opening subscriptions to them whenever you can. Components can be instantiated multiple times during your application lifecycle, which makes them difficult to manage. If you have an Observable in a component, it is much better to pass it to the template and let an async pipe handle subscribing to and unsubscribing from it.
The checkCookie() function calls the /api/isLoggedIn endpoint, which checks that the cookie exists and validates its content. Based on the API’s response, checkCookie() emits the logged-in user (or null) using the currentUser$ observable.
Because you’ve set up your cookie as httpOnly, it isn’t reachable by JavaScript. Moreover, even if it were set as a regular cookie, its value is encrypted. Without a key, you’re unable to read its content.
Once you’re done with handling service instantiation, it’s time to implement the login functionality:
public isLoggedIn(): Observable {
return this.currentUser$.pipe(
map((user) => user != null)
);
}
public setRedirectUrl(url: string) {
this.redirectUrl = url;
}
public login(email: string, password: string): void {
this.http
.post(${this.API_URL}/login
, {
email: email,
password: password,
})
.pipe(
tap((user) => {
this.currentUser$.next(user);
if (this.redirectParams) {
this.router.navigate([
this.redirectUrl,
this.redirectParams,
]);
} else {
this.router.navigate([this.redirectUrl]);
}
this.redirectParams = null;
this.redirectUrl = '/';
})
)
.subscribe();
}
The code above introduces the following functions:
-
isLoggedIn() returns an Observable emitting a boolean value based on what is emitted by currentUser$.
-
setRedirectUrl() is a setter for the redirectUrl field.
-
login() is responsible for user authentication using email and password passed as parameters. It’s emitting authenticated user data via the currentUser$ observable, based on response from the api/login endpoint. When the user is authenticated, they are redirected to the URL specified in the redirectUrl variable.
You’re not programming any “bad path” scenario for the authentication process. The reason is that if credentials provided to api/login are invalid, the endpoint will respond with the 401 status code, which will cause HttpClient to throw an error. In that case, the tap operator won’t be executed.
Last but not least, you need to implement adding a product to the list of favorites and return this list. Add the following code at the end of src/user.service.ts:
public getFavorites(): Observable<string[]> {
return this.currentUser$.pipe(
map((user) => {
if (user) {
return user.favorite;
} else {
return [];
}
})
);
}
public addToFavorites(id: string): Observable<boolean> {
return this.isLoggedIn().pipe(
take(1),
mergeMap((isLoggedIn) => {
if (isLoggedIn) {
return this.http
.post<User>(`${this.API_URL}/favorites/${id}`, {
withCredentials: true,
})
.pipe(
catchError(() => of(null)),
tap((user) => this.currentUser$.next(user)),
map((user) => !!user)
);
} else {
this.redirectUrl = 'products';
this.redirectParams = { pid: id };
this.router.navigate(['login']);
return of(false);
}
})
);
}
}
The getFavorites() method is straightforward: whenever currentUser$ is not null, it returns an observable of the favorites field from the User object. If the value emitted by currentUser$ is null, it returns an observable that emits an empty array.
The addToFavorites() function is more sophisticated. It calls the this.isLoggedIn() method and picks one value from the returned observable (you need this to avoid sending an HTTP request whenever currentUser$ emits a new value). Later it checks if the user is authenticated and performs one of two actions depending on the result:
If currentUser$ is not null, it sends a request to the /api/favorites/:id API endpoint, passing product id as a parameter. The endpoint responds with either the updated user entity or status code 401 if a cookie is missing or malformed. Based on the HTTP response, addToFavorites() does one of the following:
Emits the updated user object (containing a new array of favorite products) with the currentUser$ observable.
Changes the value emitted by the observable to null if the user is no longer authenticated (cookie has expired).
If isLoggedIn() emits false, addToFavorites() assigns redirectUrl and redirectParams fields, and redirects the user to the login page by routing the application to the /login URL.
Implementing AuthGuard
You are about to implement the AuthGuard service, which checks if the user should be able to navigate to areas that are not available for unauthorized users. Replace the code in src/app/auth-guard.service.ts with the following:
AuthGuard checks the value returned by the isLoggedIn() method in the user service. If the value is false, AuthGuard assigns the user service’sredirectUrl and redirects the user to the /login route. Using the redirectUrl field makes sure that after successfully logging in, the user is redirected to the page that AuthGuard prevented him from reaching.
Routing
Let’s utilize what you have implemented so far. Change the application routing code in src/app-routing.module.ts to the following:
import { LoginComponent } from ‘./login/login.component’;
import { FavoritesComponent } from ‘./favorites/favorites.component’;
import { AuthGuardService } from ‘./auth-guard.service’;
const routes: Routes = [
{ path: ‘’, redirectTo: ‘products’, pathMatch: ‘full’ },
{ path: ‘products’, component: ProductsListComponent },
{ path: ‘login’, component: LoginComponent },
{
path: ‘favorites’,
component: FavoritesComponent,
canActivate: [AuthGuardService],
},
];
Login component
You are about to implement LoginComponent. Unauthorized users should be redirected to this component whenever they are trying to reach the restricted area protected by the AuthGuard.
Add the following code to src/app/login/login.component.ts:
You’ve implemented a reactive form that collects email and password from the user. Upon submitting this form, the data is sent to the UserService.login() method. If the user provides correct data, UserService.login() redirects them to the route that they were trying to reach.
Navigation
Last but not least, let’s adjust HeaderComponent by introducing buttons to navigate to /favorites and /login paths.
Replace the code in src/app/header/header.component.ts with the following:
You have injected UserService into HeaderComponent and instantiated the isLoggedIn$ field of the Observable type. Based on the value emitted by that observable, you show the user one of two buttons: login or my favorite products
Testing the authentication flow
The following credentials are hardcoded in the database: {email: name@email.com, password: abc123}.
Navigate to your application and check if it works as expected:
Notice that when you reload the website, you’re losing the cookie set up by the backend. This is not a bug in our application. The problem is that you are running your app on localhost. (Un)fortunately, browsers have some restrictions on how they are dealing with cookies. For example, localhost is not recognized as a regular domain, so cookies sent by your backend are not persisted in the browser (because the backend is running on a different port than the frontend).
Once you’re done with testing on localhost, change value of the API_URL variable in the src/app/user.service.ts file from:
Since now you will no longer use different domain for front-and and back-end so this link can be a relative path.
Building the Products Service
It’s time to enable displaying products in your application. In this step, you are going to use product images that are available for download here. Place the images inside the src/assets/images folder in your project.
To generate the component and the service that you are going to implement, type the following commands in the terminal:
Products service
Before digging into the service, let’s declare the Product interface that you will use across the application. Create a new file called product.model.ts in the src/model directory and copy the following code:
To implement ProductsService, add the following code to src/products.service.ts:
ProductsService is consuming two API endpoints:
-
GET /products that returns the list of all products (without product descriptions).
-
GET /products/:id that returns the full description of the product specified by the :id query parameter.
Notice that you’ve assigned the value /api to the API_URL constant. From now on, you are going to run your frontend together with the backend. You don’t need to specify the whole URL of the API anymore because it will be served from the same domain as the frontend. Apply the same modification in src/app/user.service.ts and change API_URL accordingly.
Product details component
You now need to add ProductDetailsComponent to the application routing. Change routes in src/app-routing.module.ts to the following:
import { ProductDetailsComponent } from './product-details/product-details.component';
const routes: Routes = [
{ path: '', redirectTo: 'products', pathMatch: 'full' },
{ path: 'products', component: ProductsListComponent },
{
path: 'product/:id',
component: ProductDetailsComponent,
},
{ path: 'login', component: LoginComponent },
{
path: 'favorites',
component: FavoritesComponent,
canActivate: [AuthGuardService],
},
];
To consume ProductsService in ProductDetailsComponent, replace the content of src/app/product-details/product-details.component.ts with the following TypeScript code:
import { Component, OnInit, Input } from '@angular/core';
import { Product } from 'src/model/product.model';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ProductsService } from '../products.service';
import {
switchMap,
tap,
mergeMap
} from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { UserService } from '../user.service';
@Component({
selector: 'app-product-details',
templateUrl: './product-details.component.html',
styleUrls: ['./product-details.component.scss'],
})
export class ProductDetailsComponent implements OnInit {
@Input() product: Product;
@Input() isFavorite: boolean;
public product$: Observable<Product>;
constructor(
private route: ActivatedRoute,
private ps: ProductsService,
private us: UserService
) {}
ngOnInit(): void {
if (this.product) {
this.product$ = of(this.product);
} else {
this.product$ = this.us.getFavorites().pipe(
mergeMap((favorites) => {
return this.route.paramMap.pipe(
switchMap((params: ParamMap) =>
this.ps.getProduct(params.get('id'))
),
tap(
(product) =>
(this.isFavorite = favorites.includes(
product.id
))
)
);
})
);
}
}
public addToFavorites(id: string) {
this.us.addToFavorites(id).subscribe();
}
}
ProductDetailsComponent expects two inputs:
-
product: defines which details should be displayed.
-
isFavorite: defines if a product is one of the user’s favorite products.
This component will be used in two ways:
As a sub-component of FavoritesComponent or ProductsListComponent.
As a standalone component to display a given product landing page.
You have to handle both situations. In the ngOnInit() method, you are checking if @Input() product has been provided. If not, you retrieve product and determine if it is the user’s favorite using ProductsService and UserService.
To implement a template for ProdcutDetailsComponent, add the following HTML to src/app/product-details/product-details.component.html:
<ng-container *ngIf="product$ | async as p">
<div class="card mb-4 shadow-sm">
<div class="bd-placeholder-img card-img-top">
<img src="assets/images/{{p.image}}.png" alt="pictogram of {{p.name}}" />
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-3">{{p.name}}</h4>
<small class="text-muted">{{p.price | currency}}</small>
</div>
<p *ngIf="p.description" class="card-text">{{p.description}}</p>
<div class="btn-group">
<a *ngIf="!p.description" [routerLink]="['/product', p.id]"
class="btn btn-sm btn-outline-secondary">{{p.name}} description</a>
<button *ngIf="!isFavorite" (click)="addToFavorites(p.id)"
class="btn btn-sm btn-outline-primary">add to favorites</button>
</div>
</div>
</div>
</ng-container>
To add styling to the component, add the following CSS into src/app/product-details/product-details.component.scss:
.bd-placeholder-img {
background: #eeeeee;
width: 100%;
height: 250px;
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
}
Products list and favorites
You can now use ProductDetailsComponent to display products retrieved via the API. Replace the content of src/app/products-list/products-list.component.ts with the following:
import { Component, OnInit } from '@angular/core';
import { ProductsService } from '../products.service';
import { Observable } from 'rxjs';
import { Product } from 'src/model/product.model';
import { UserService } from '../user.service';
import { ActivatedRoute } from '@angular/router';
import { mergeMap, filter } from 'rxjs/operators';
@Component({
selector: 'app-products-list',
template: `
<h1 class="mb-3">All products</h1>
<div class="row">
<app-product-details
*ngFor="let product of products$ | async"
[product]="product"
[isFavorite]="
(userFavorites$ | async)?.includes(product.id)
"
class="col-md-4"
></app-product-details>
</div>
`,
styles: [],
})
export class ProductsListComponent implements OnInit {
public products$: Observable<Product[]>;
public userFavorites$: Observable<string[]>;
constructor(
private ps: ProductsService,
private us: UserService,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.route.paramMap
.pipe(
filter((params) => params.keys.length > 0),
mergeMap((params) =>
this.us.addToFavorites(params.get('pid'))
)
)
.subscribe();
this.products$ = this.ps.getProducts();
this.userFavorites$ = this.us.getFavorites();
}
}
Let’s see what’s inside ngOnInit(). If you recall, UserService contains a method called addToFavorites(). If this method is called by an unauthenticated user, the application redirects them to the login page. After successful authentication, the user is redirected to the /products page. The ID of the product (pid) that the user wants to add to favorites is passed as a query parameter. You are checking if this parameter appears in the URL. If it does, it means you should call the addToFavorites() method.
To use ProductDetailsComponent inside FavoritesComponent, replace the content of src/app/favorites/favorites.component.ts with the following code:
import { Component, OnInit } from '@angular/core';
import { UserService } from '../user.service';
import { ProductsService } from '../products.service';
import { map, mergeMap } from 'rxjs/operators';
@Component({
selector: 'app-favorites',
template: `
<h1 class="mb-3">Your favorite</h1>
<div class="row">
<app-product-details
*ngFor="let product of favorite$ | async"
[product]="product"
[isFavorite]="true"
class="col-md-4"
>
</app-product-details>
</div>
`,
styles: [],
})
export class FavoritesComponent implements OnInit {
public favorite$;
constructor(
private us: UserService,
private ps: ProductsService
) {}
ngOnInit(): void {
this.favorite$ = this.us.getFavorites().pipe(
mergeMap((favoriteProducts) => {
return this.ps.getProducts().pipe(
map((allProducts) => {
return allProducts.filter((product) =>
favoriteProducts.includes(product.id)
);
})
);
})
);
}
}
Testing the app
Build your application:
npm run build
npm start
Navigate to your application and verify that it works as expected:
Deploying and Measuring Performance
The code you’ve built thus far is available for download here. Once you download it, remember to install the project using the npm install command inside the project directory.
Your application is ready. You’ve built it using the ng build --prod command that minifies JavaScript, performs tree shaking and other kinds of magic to boost performance. Let’s test it in the battlefield. That’s the moment where you are going to use the Heroku account, so if you don’t have one sign up for it. You should also have Heroku CLI installed on your machine.
There are, of course, tons of great places you can host your Angular app. AWS has a dozens of ways you could deploy it, even more adding Google Cloud, Azure, Digital Ocean, etc.
To keep thing “simple” we’re going to focus on Heroku in this chapter, but know that the same principles apply to any deployment scenario where you can run the Node.js server.
Creating a Heroku application
Use Heroku CLI to create a new project:
heroku create
After executing this command, Heroku CLI should set up a Git remote in your Angular project. Verify that by executing the following command:
git remote -v
Deploying to Heroku
This task is straightforward - just push your changes to the Heroku Git:
git add .
git commit -am "make it better"
git push heroku master
Measuring performance
Let’s measure performance of the server’s response. You can use the Byte Check website to measure time to first byte (TTFB) for any website available on the Internet. It’s a really good practice to use systems like this instead of performing tests on your machine, as they don’t rely on your home network connection.
After performing a few tests, you can see that TTFB of your application is around 70 ms. Quite impressive. Of course, server response time is important, but it’s not a useful metric for Angular applications.
Because of Heroku’s nature, it’s good to perform up to 2 requests to boot up the machine before you start real testing.
Let’s check what the page speed index is. You can do this using the Lighthouse tab in Chrome Developer Tools:
This isn’t too impressive. It took 2.6 seconds to ship the first contentful paint to the user.
To double-check, let’s also use the PageSpeed Insights tool from Google.
Paste your website URL in the URL field and hit Analyze:
The single page app pitfall
You can see that your page doesn’t load as quickly as you’d like. Why?
The answer is simple: because you’ve built a single-page application (SPA).
SPAs are a great solution! They give users a native-like app experience with smooth transitions, you don’t need to bother with preparing your application for different browsers (the framework takes care of that), and much more. However, they have at least one big pitfall: they are rendered in the browser rather than on the server. To understand the problem, consider the browser’s simplified workflow. The browser:
Establishes a connection with the server (querying DNS, obtaining SSL connection, etc.) and requests data
Retrieves the data
Renders HTML
Applies CSS
Bootstraps JavaScript
As you can see, the moment when your Angular application is bootstrapped is at the very end of the stack. That’s why it takes so long to render the first contentful paint.
SPAs have one more pitfall. Because content is rendered in the browser, the index.html file shipped to the end user has no content. What if your end user is a web crawler? Which of these two tweets looks better?
What if this web crawler is Googlebot or an agent sent by another search engine? Here’s all it can parse from your HTML:
That’s literally nothing. You might say that Googlebot can render and index JavaScript, but which website do you think it will rank higher: one that renders a view for their users or one that doesn’t care about user experience and lets their machine render the output?
Yet another problem arrises when user has disable JavaScript in his browser. In such case your application is completely unusable. You can mimic that with Chrome Developer tools by disabling JavaScript (control+shift+P or cmd+shift+P):
Server-side rendering - the panacea for SPA problems
Fortunately, there is a solution to the problems discussed above: server-side rendering.
Server-side rendering is a JavaScript bootstrapping technique that renders the view on the server and sends the output to the user. In Angular, it is called Angular Universal. Applying basic server-side rendering to an existing Angular application is straightforward, but with some effort, you can boost it to work faster and make your app a performance beast. That’s what you are going to learn in this course.
Angular Universal
The code of the application built in the previous module is available for download here. Once you download it, remember to install the project using the npm install command inside the project directory.
Where you are
In the previous module, you’ve built a fully functional production-ready application. Unfortunately, like other single-page applications, this application has its weaknesses. In this module, you will learn how to address one of them and render HTML content on the server.
What you will build
In this module, you will enhance your Angular application by adding Angular Universal. Thanks to server-side rendering, your application will be readable for search engine robots and other web crawlers. In addition, you will significantly improve the application’s boot performance, resulting in faster first meaningful paint.
What you will learn
After finishing this module, you will know:
-
How to add Angular Universal to an existing Angular application.
-
What are the benefits of server-side rendering.
-
How Angular Universal changes your application’s boot process.
-
How to adjust your application to prevent breaking SSR (i.e., by using the window object).
-
How to dynamically change and meta tags of your website.
Applying schematics
Starting your adventure with Angular Universal is as simple as applying a single command using the Angular CLI. Navigate to your project root directory and type the following in the terminal:
ng add @nguniversal/express-engine
Angular CLI will install all the necessary dependencies for you and apply schematics (a set of instructions on how to adjust the project). After applying the schematics, you should see the summary output of what has been changed, added, or deleted in your project:
Let’s review these changes.
Dependencies and scripts
The backbone of your project is the package.json file. Angular CLI has installed new dependencies there that are necessary to run Angular in the Node.js runtime environment:
@angular/platform-server
@nguniversal/express-engine
@nguniversal/builders
@types/express
Another change is in the scripts section:
"scripts": {
"ng": "ng",
"start": "node dist/backend/backend.js",
"build": "ng build --prod && npm run build:backend",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"build:backend": "tsc --project tsconfig.backend.json",
"dev:ssr": "ng run my-universal-app:serve-ssr",
"serve:ssr": "node dist/my-universal-app/server/main.js",
"build:ssr": "ng build --prod && ng run my-universal-app:server:production",
"prerender": "ng run my-universal-app:prerender"
},
Angular CLI has added the following new scripts:
-
dev:ssr is an equivalent of the ng serve command. You should use it for development purposes. The difference between npm run dev:ssr and ng serve is that the former will compile both server-side and browser-side code for you. This enables you to check how your application behaves in the server environment. Both solutions are watching your project for changes, so whenever you apply changes in code, they will automatically trigger the compilation process.
-
build:ssr is an equivalent of ng build --prod. This command should be used to compile the production-ready application. Once it is applied, you will see compilation output inside the dist//browser and dist//server folders in your project.
-
serve:ssr is used to serve the application in production mode. This script runs the compiled server source code, which resides in the dist//server folder.
-
prerender is a script that you can use to generate HTML and JavaScript for static hosting - we will cover this topic later in the course.
Builders
Under the hood, the scripts described above use a set of builders added to your project. You can find them in the angular.json file:
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/my-universal-app/server",
"main": "server.ts",
"tsConfig": "tsconfig.server.json"
},
"configurations": {
"production": {
"outputHashing": "media",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"sourceMap": false,
"optimization": true
}
}
},
"serve-ssr": {
"builder": "@nguniversal/builders:ssr-dev-server",
"options": {
"browserTarget": "my-universal-app:build",
"serverTarget": "my-universal-app:server"
},
"configurations": {
"production": {
"browserTarget": "my-universal-app:build:production",
"serverTarget": "my-universal-app:server:production"
}
}
},
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"browserTarget": "my-universal-app:build:production",
"serverTarget": "my-universal-app:server:production",
"routes": [
"/"
]
},
"configurations": {
"production": {}
}
}
As you can see, the server-side rendering build will use a new entry point to your application, server.ts.
Node.js application
The server.ts file is where the Node.js application resides.
import 'zone.js/dist/zone-node';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { join } from 'path';
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { existsSync } from 'fs';
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
const server = express();
const distFolder = join(process.cwd(), 'dist/my-universal-app/browser');
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;
}
function run() {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app();
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 './src/main.server';
One of the functions exported from server.ts is the app function. It returns an Express.js application called server - this is where HTTP request handlers reside. The server application uses ngExpressEngine to generate HTML, which is the essence of Angular Universal. ngExpressEngine bootstraps the Angular framework using the AppServerModule object imported from src/main.server.ts. That’s the entry point of the application. All that logic is handled under the server.get(’’) route. server contains one more route, server.get(’.*’), that is responsible for hosting all other assets that the browser will request for:
-
The browser-side Angular application
-
All other JavaScript files
-
Images
-
Fonts
-
CSS files
-
Any other static files.
Server-side Angular
The src/main.server.ts file contains two exports:
AppServerModule is the main module of your application imported from src/app.server.module.ts.
renderModule and renderModuleFactory are functions that can be used for the server-side rendering process if you don’t want to use the engine provided in the @nguniversal package.
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';
Finally, let’s examine AppServerModule that resides in src/app.server.module.ts:
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
imports: [
AppModule,
ServerModule,
],
bootstrap: [AppComponent],
})
export class AppServerModule {}
This module imports AppModule where all your components, modules, services, and other Angular-specific entities are defined. The second import is ServerModule containing server-specific objects like Request. Inside this module, you can specify any server-specific logic and override the functionality defined in AppModule.
Integrating API with server.ts
Once you have server.ts, you no longer need backend.ts and tsconfig.backend.json. However, you are going to use these files in further course lessons, so make sure to keep them around.
Since you’ve set up your API in a separate file, api.ts, you can easily integrate it with the server application. Import your API in server.ts:
import { api } from './api';
Now register it under the /api route of the server application:
server.get(
'*.*',
express.static(distFolder, {
maxAge: '1y',
})
);
server.use('/api', api);
// All regular routes use the Universal engine
server.get('*', (req, res) => {
res.render(indexHtml, {
req,
providers: [
{ provide: APP_BASE_HREF, useValue: req.baseUrl },
],
});
});
Add the /api route before the * wildcard path.
Compile to check if the API works as desired using Postman:
npm run dev:ssr
or cURL:
curl http://localhost:4200/api/products
Angular Universal flow
The current application flow is depicted in the diagram below.
When the user navigates to the website ( GET / ), Node.js fires the compiled server.ts file. After the server application recognizes that this is an Angular path, ngExpressEngine bootstraps Angular and renders HTML that is sent to the browser. Once the browser renders the HTML, it queries the server for linked resources like images, CSS and JavaScript. Then JavaScript kicks off in the browser, launches the logic included in main.ts, and starts browser-side Angular.
The problem of backend calls
Integrating an existing Angular application with Angular Universal can sometimes be a bumpy ride. It’s no different in this case. Try to navigate to http://localhost:4200 in your browser. You should see the NetworkError in the terminal where your Node.js application is running:
On the one hand, your application has been shipped to the browser and rendered in it. On the other hand, your SSR process is broken, and you should never ignore such errors. You can use Postman to examine what HTML has been produced by the backend:
Equivalent cURL command is:
curl http://localhost:4200
As you can see, the application is rendered partially: information about available products is missing.
The problem lies in how you are performing backend calls. Let’s take a look at ProductsService in src/app/products.service.ts where API_URL is defined as a relative URL:
private API_URL = '/api';
That’s OK for browser-side code because the browser knows what domain you are navigating and adds the necessary base path (in other words, it translates relative URL /api/products to https://yourdomain.com/api/products). The backend doesn’t have this knowledge, so the XMLHttpRequest used by HttpClientModule can’t figure out what URL you are trying to query.
You can fix this issue in several ways. One would be to provide an HTTP interceptor in app.server.module.ts to deal with such requests and add the missing domain. We’ll implement a different approach that involves the environment constant. Add a new variable, apiBasePath, to src/environments/environment.ts:
export const environment = {
production: false,
apiBasePath: 'http://localhost:4200'
};
Make a similar change to src/environments/environment.prod.ts:
export const environment = {
production: true,
apiBasePath: 'https://your-application-name.herokuapp.com'
};
Finally, change the values of API_URL in UserService and ProductsService accordingly:
import { Injectable } from '@angular/core';
import { environment } from '../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ProductsService {
private API_URL = `${environment.apiBasePath}/api`;
/*
service logic
*/
}
Your application should now be fully rendered on any request to the backend:
Or verify rendering process using cURL:
curl http://localhost:4200
Deploying to Heroku
It’s the right moment to check how applying Angular Universal changes the application’s performance.
Change the start and build scripts in package.json:
"start": "npm run serve:ssr",
"build": "npm run build:ssr",
You may also remove the build:backend script as it is no longer needed.
Deploy the application by pushing changes to the Heroku repository:
git add .
git commit -m "applying ssr" .
git push heroku master
Performance
First things first, let’s check if the backend launches Angular and renders HTML. Use Postman to perform a GET request to your application’s home page:
Once you’ve ensured that the application works as expected, perform Time To First Byte (TTFB) tests using Byte Check:
It’s around 113 ms. Compared to the 70ms achieved without Universal, this doesn’t look optimistic.
However, as we agreed in the past, TTFB is not everything. What you should be more interested in is First Contentful Paint (FCP). Perform the test using Chrome Developer tools:
Now the results look drastically better, even though the total index is worse. FCP has been shipped in 2.1 seconds, while in the previous test without SSR it took 2.6 seconds.
Let’s also check how the application performs in a more restrictive PageSpeed Insights test:
This tool reports a decrease in performance as well. No worries though: you will address this in future course modules.
Here is what the summary results look like:
The following diagram compares performance with and without SSR:
As you can see, the TTFB value doesn’t have a significant impact on the Time To Interactive value. So what does? What does the mysterious “other” label in the diagram stand for? Let’s recall the browser flow described in the previous module. Here is how your application is shipped to the end user without SSR:
-
Establish a connection with the server and query for data.
-
Retrieve the data (Time to First Byte).
-
Render HTML and apply CSS.
-
Bootstrap JavaScript and Angular, then render views in the browser (First Contentful Print).
-
Bind functions to HTML elements, execute other scripts (other).
What does it look like when SSR is applied?
-
Establish a connection with the server and query for data.
-
Retrieve the data (Time to First Byte).
-
Render HTML and apply CSS (First Contentful Print).
-
Bootstrap JavaScript and Angular, then render views in the browser (again), then execute other scripts (other).
When you apply server-side rendering to your application, you bootstrap Angular twice: first on the server and then in the browser. That’s the reason why the other rectangle is so prominent with SSR.
In the following modules, you will learn techniques to significantly decrease that scripting time!
The Browser Is Not The Server
What can break SSR?
In the beginning of this module, you’ve already had a chance to see that some actions can be performed in the browser but not on the server. One such action is performing a REST call using a relative URL. However, this is not the only action that might cause the SSR process to fail.
There are some global objects that are exclusive to the browser:
window can be used to display alerts to the user.
document belongs to the window namespace and is used to manipulate DOM elements.
navigator belongs to the window namespace and enables service workers that are used extensively with Progressive Web Applications.
There is also a set of objects that are exclusive to the server:
fs represents the file system and is used for CRUD operations.
request represents the HTTP request retrieved by the server.
Your Angular application has a shared code base for server and browser environments. That’s good because you don’t need to repeat yourself. However, if you want to use any of these objects, you need to execute a different logic path based on the current runtime: Node.js or the browser window. In this part, you will learn one of the techniques of doing that.
Adding internationalization
Let’s add internationalization to your application. Don’t worry, I’m not going to ask you to translate every product description. For now, let’s display product prices in three currencies: US dollars, British pounds, and Polish zloty. The application should pick a currency based on browser settings, and if a given language is not supported, it should fall back to Polish zloty.
Let’s generate a new service:
ng g s i18n
Now let’s detect user language and implement the getCurrencyCode() method that returns one of the three available currency codes:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class I18nService {
private userLang;
constructor() {
this.userLang = window.navigator.language;
}
public getCurrencyCode(): string {
switch(this.userLang) {
default:
case 'pl-PL': return 'PLN';
case 'en-US': return 'USD';
case 'en-EN': return 'GBP';
}
}
}
Import the service in src/app/product-details/product-details.component.ts:
import { I18nService } from '../i18n.service';
Set up a new public field userCurrency and inject the service in the constructor:
public userCurrency: string = this.i18n.getCurrencyCode();
constructor(
private route: ActivatedRoute,
private ps: ProductsService,
private us: UserService,
private i18n: I18nService
) { }
Modify the currency pipe in src/product-details/product-details.component.html:
<small class="text-muted">{{p.price | currency: userCurrency}}</small>
From now on, prices should display in a currency defined by the user’s localization settings. Unfortunately, this logic breaks SSR:
isPlatformBrowser() and isPlatformServer()
It would help if you had a mechanism to detect whether the current runtime is the browser or the server. For this purpose, Angular ships with the isPlatformBrowser() and isPlatformServer() methods in the @angular/common package. Each of these methods accepts one parameter: the platform ID. It can be retrieved via the Dependency Injection mechanism using the injection token PLATFORM_ID available in the @angular/core package.
Add new imports to src/i18n/service.ts:
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
Modify the service constructor to only use the window object if an instance of the service executes in the browser:
constructor(@Inject(PLATFORM_ID) private platformId: any) {
if(isPlatformBrowser(this.platformId)) {
this.userLang = window.navigator.language;
} else {
// server specific logic
}
}
This should be enough for SSR to start working again.
The Request object
The question now is how to retrieve information about user language on the server. Is it even possible? Yes, it is. When you’re performing a request from the browser, it always adds some HTTP headers to it. One of these headers is Accept-Language that can be used like this: Accept-Language: en-US,en;q=0.5. Angular Universal allows you to get an object that represents an HTTP request. It’s available via Dependency Injection under the REQUEST token from the @nguniversal/express-engine/tokens package. The Request object contains the following fields:
-
body
-
params
-
headers
-
cookies
Update imports in src/i18n.service.ts by adding the Request object, the REQUEST injection token, and the Optional decorator:
import { Injectable, Inject, PLATFORM_ID, Optional } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';
Change the constructor to inject the Request object and retrieve user language from the Accept-Language header:
constructor(
@Inject(PLATFORM_ID) private platformId: any,
@Optional() @Inject(REQUEST) private request: Request
) {
if(isPlatformBrowser(this.platformId)) {
this.userLang = window.navigator.language;
} else {
this.userLang = (this.request.headers['accept-language'] || '').substring(0, 5);
}
}
Here’s what i18nService should look like after these updates:
import { Injectable, Inject, PLATFORM_ID, Optional } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';
@Injectable({
providedIn: 'root'
})
export class I18nService {
private userLang;
constructor(
@Inject(PLATFORM_ID) private platformId: any,
@Optional() @Inject(REQUEST) private request: Request
) {
if(isPlatformBrowser(this.platformId)) {
this.userLang = window.navigator.language;
} else {
this.userLang = (this.request.headers['accept-language'] || '').substring(0, 5);
}
}
public getCurrencyCode(): string {
switch(this.userLang) {
default:
case 'pl-PL': return 'PLN';
case 'en-US': return 'USD';
case 'en-EN': return 'GBP';
}
}
}
Notice the usage of the @Optional() decorator. Whenever your code executes in the browser where the Request object is not available, this decorator enables the request variable to contain null. In fact, instead of using the isPlatformBrowser() method, you may redesign your constructor to the following form:
constructor(@Optional() @Inject(REQUEST) private request: Request) {
if (!this.request) {
this.userLang = window.navigator.language;
} else {
this.userLang = (this.request.headers['accept-language'] || '').substring(0, 5);
}
}
Use Postman to verify that language settings fall back to Polish zloty when the Accept-Language header is missing:
The equivalent curl+grep command is:
curl http://localhost:4200/products | grep PLN