Run yarn
in project root to install all the dependencies.
First, start the mock-data server with npm run start:mock-data-server
. By default the server is running on port 4201, you can set custom port via PORT
environment variable (PORT=6666 npm run start:mock-data-server
).
To build and run SSR version of the app, run npm run start:ssr
. The app relies on API_BASE
environment variable, so make sure it is exported.
# use mock-data api base url
export API_BASE="http://localhost:4201"
# test if it is exported correctly
echo $API_BASE
# build and run SSR version of the app
npm run start:ssr
There are three main reasons to create a Universal version of your app:
- Facilitate web crawlers (SEO)
- Improve performance on mobile and low-powered devices
- Show the first page quickly
[Official Angular Universal guide]
Available APIs are different depending on where the app is running. If the app is running in browser, you will have access to things like window
, and if the app is running in node window
will be undefined but you will have access to Request
, Response
, global
, environment variables and some other useful things like fs
.
In order to avoid exceptions because of API unavailability on certain platform, we can safe-guard execution of platform-specific code:
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
//...
@Component({
//...
})
export class MyComponent {
constructor(@Inject(PLATFORM_ID) private platformId: object) { }
private get isPlatformBrowser(): boolean {
return isPlatformBrowser(this.platformId);
}
private get isPlatformServer(): boolean {
return isPlatformServer(this.platformId);
}
}
Some routes might be accessible only if the user is logged in. This means that, when rendering, the server has to know if the user is logged in. This automatically rules out storing of session info in localStorage
and only option is to use cookies for storing login token or whatever info is necessary to determine user auth status. Cookies are the only options because they are sent by the browser when making the initial call for index page.
The process can be described as follows:
- User visits
myapp.com
for the first time - User logs in, JS saves token in a cookie
- User closes the browser.
- User opens the browser and enters
myapp.com/user-profile
in address bar - Browser makes a
GET
request forhttp://myapp.com/user-profile
and sends cookie in request header:- Cookie: token=ey...
- Node server reads cookie from the request and makes an authorization check call to the API
- if the token from cookie is valid, authorize the user in app
- Auth route guard allows navigation to
user-profile
since the user is authorized - Node server renders
user-profile
route - Server returns rendered
HTML
If user navigates to a non-existent route, it is expected that a 404
status code is returned. However, out of the box this does not happen.
What happens is:
- User navigates to
myapp.com/{slug}
- Server makes call to the API to fetch data for page via
slug
. - API returns 404
- Server renders component with title
Page not found
- Rendered HTML is returned with status
200
Additional step is required somewhere between steps 3 and 5:
- User navigates to
myapp.com/{slug}
- Server makes call to the API to fetch data for page via
slug
. - API returns 404
- Catch page fetching errors and set
express
Response
status. - Server renders component with title
Page not found
- Rendered HTML is returned with status
404
Same method can be used to return status 401
if user tries navigating to myapp.com/user-profile
when not logged in.
Page fetching service:
import { RESPONSE } from '@nguniversal/express-engine/tokens';
import { Response } from 'express';
//...
@Injectable()
export class PageFetchingService {
constructor(
private http: HttpClient,
private platform: PlatformService,
@Optional() @Inject(RESPONSE) private response: Response,
) { }
public fetchPage(slug: string): Observable<Page> {
this.http
.get(`api.com/page?slug=${slug}`)
.pipe(
catchError((error: HttpErrorResponse) => {
if (this.platform.isServer) {
this.response.status(error.code);
}
}),
map((pageData: IPage) => {
return new Page(pageData);
}),
);
}
}
server.ts
:
//...
import { ngExpressEngine, RenderOptions } from '@nguniversal/express-engine';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';
app.engine('html', (
filePath: string,
options: RenderOptions,
callback: Function,
) => ngExpressEngine({
bootstrap: AppServerModuleNgFactory,
providers: [
provideModuleMap(LAZY_MODULE_MAP),
{
provide: REQUEST,
useValue: options.req,
},
{
provide: RESPONSE,
useValue: options.req.res,
},
],
})(filePath, options, callback));
//...
There is a mechanism for transfering data from server to client, via TransferState
service.
If server sets some data for transfer, it is returned to the browser via <script>
tag. For example:
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... -->
</head>
<body>
<!-- ... rendered page ... -->
<script id="universal-demo-state" type="application/json">
{ &q;homepageData&q;:{&q;title&q;:&q;Welcome&q;,&q;text&q;:&q;Angular
Universal Demo & q;,
& q;time & q;: 1531076035630
}
}
</script>
</body>
</html>
This mechanism can be used for multiple things, as described in chapters 4.5 and 4.6.
In order to render some pages, app probably has to fetch data from API. This call will be made on the server as well in the browser once the app bootstraps. To avoid unnecessary duplicate calls for same data on both platforms, we can make the call only on server and transfer the data to browser.
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { Observable, of as observableOf } from 'rxjs';
import { map, tap } from 'rxjs/operators';
//...
@Injectable()
export class HomepageService {
private readonly hompeageStateKey = makeStateKey<IHomepageContent>('homepageData');
constructor(
private http: HttpClient,
private platform: PlatformService,
private transferState: TransferState,
) { }
public getHomepage(): Observable<HomepageContent> {
// Check if data is transferred
if (this.platform.isBrowser && this.transferState.hasKey(this.hompeageStateKey)) {
const transferedHomepageData: IHomepageContent = this.transferState.get(this.hompeageStateKey, null);
// Clear from store to avoid 'caching'
this.transferState.remove(this.hompeageStateKey);
const homepage = new HomepageContent(transferedHomepageData);
return observableOf(homepage);
}
// If no data was transferred or the data was cleared, make the call
return this.http.get(`${this.apiBase}/homepage`).pipe(
tap((homepageData: IHomepageContent) => {
if (this.platform.isServer) {
this.transferState.set(this.hompeageStateKey, homepageData);
}
}),
map((homepageData: IHomepageContent) => {
return new HomepageContent(homepageData);
}),
);
}
}
There are two options for handling environment variables:
- Inject values at build-time (via
webpack
or some other method) - Read them at run-time on the server and transfer them to the browser
Second option might be required in some cases. For example, if the app is build once and then a Docker image is created with that build. That Docker image might be placed in multiple environments. If first option is employed, app will have to be rebuilt whenever Docker image is placed in a different environment. This might required re-distribution of the image (annoying and not streamlined). Also note that second option is possible only with SSR. App without SSR will have to use injected values from build-time (deal with it).
Environment variables service might look something like this:
@Injectable()
export class EnvironmentVariablesService {
private readonly transferKey = makeStateKey('envVars');
private vars: IDictionary<string> = {};
constructor(
private platform: PlatformService,
private transferState: TransferState,
) {
if (this.platform.isServer) {
exposedEnvironmentVariables.forEach((envVarName) => {
this.vars[envVarName] = process.env[envVarName];
});
this.transferState.set(this.transferKey, this.vars);
}
if (this.platform.isBrowser) {
this.vars = this.transferState.get(this.transferKey, {});
}
}
public getVar(name: ExposedEnvironmentVariable): string {
if (name in this.vars) {
return this.vars[name];
}
const defaultValue = defaultEnvironmentVariablesValues[name];
return defaultValue;
}
}
Run ng serve
for a dev server. Navigate to http://localhost:4200/
. The app will automatically reload if you change any of the source files.
Run ng generate component component-name
to generate a new component. You can also use ng generate directive|pipe|service|class|guard|interface|enum|module
.
Run ng build
to build the project. The build artifacts will be stored in the dist/
directory. Use the --prod
flag for a production build.
Run ng test
to execute the unit tests via Karma.
Run ng e2e
to execute the end-to-end tests via Protractor.
To get more help on the Angular CLI use ng help
or go check out the Angular CLI README.