- Firebase
- CI
- Codeship
- Development
Black Sand Solutions
Server Side Rendering With Angular & Firebase
- Angular
- SSR
- Firebase
A step by step guide on how to set up server side rendering (SSR) with Angular 5 and Firebase hosting
Server Side Rendering With Angular & Firebase
There's several guides on the web about how to do this, however none of them worked for me. Most of these issues were due to differences in my environment and the guides - Node, Angular, Angular CLI, Firebase etc. That said, they all contributed to my arriving at this solution.
Note: I'm going to assume you already know what Server Side Rendering is and what the benefits are.
Environment
This is the environment used for this solution.
- Windows 10
- node 6.11.5
- npm 5.5.1
- angular 5.2.0
- angular CLI 1.7.3
- firebase tools 5.5.1
- typescript 2.8.3
Note: I purposefully fixed my Angular CLI version, as the latest version of the CLI installs Anguaalr 6 and RxJS & I wanted to keep the number of changes to a minimum. Note: Normally I use Node 8.9.1 but this is incompatible with the firebase-functions emulator. I therefore installed nvm-windows which allows me to run multiple versions of Node on my machine.
PreRequisites
Ensure that you have firebase tools and the AngularCLI installed; ideally at the versions above.
NVM (If Required)
- Install nvm-windows from here.
- Install nvm 6.11.5 (cannot run firebase functions emulator with later versions)
nvm install 6.11.5
Create A New Project
- Use Ng cli (1.7.3) to create a new project
ng new <project>
ng serve
to check it actually runs!
At this point you should have the default angular seed project example running in your browser.
Set Up Firebase Hosting
-
Set up firebase hosting (windows cmd)
firebase init hosting
- choose a project
- set the public dir as
dist
- rewrite all urls to index (Y)
Test Deployment
- build app for deployment
ng build --prod
- createsdist
folder - test deploy
firebase deploy
- check the site at hosting url:
https://<project-id>.firebaseapp.com
- view source - should see
<app-root></app-root>
and no other angular created content as this is NOT SSR.
Set Up Firebase
Now we are going to set up firebase hosting and functions. Hosting will be used to host the production application and functions will be used to create the server side version.
Set Up Firebase Functions
-
set up firebase functions
firebase init functions
- use existing project (skip)
- don't lint (let's introduce the minimum changes right now)
- use javascript (let's introduce the minimum changes right now)
- install depedendencies
Test Functions
- uncomment the hello world example and save the file
- start the emulator
firebase serve --only functions
- open url in browser:
http://localhost:5000/<project-id>/us-central1/helloWorld
- confirm you see
Hello World
- stop emulator
Great, functions are set up and working. Let's move on.
Test Hosting & Functions
Why? Because I have had issues with this working before and I want to know each step works. This helps narrow down the location of bugs
firebase serve
You should see the application that was built to the dist folder served at http://localhost:5000/
And the functions should be served at: http://localhost:5000/<project-id>/us-central1/helloWorld
Start SSR Setup
When our app is loaded, we want to render the initial page server side and return that instead of the default index normally served.
Modify Angular App
In order to build the server side applicaiton we need to install @angular/platform-server
.
Ensure the version matches the rest of your angular dependencies.
npm install @angular/platform-server@5.2.0
update app.module
Open app.module.ts
and modify the imports like so:
imports: [
BrowserModule.withServerTransition({ appId: 'ssrapp' }),
],
app.server.module
Create a new file src/app/app.server.module.ts
.
This is an ng module for the server; it tells the server what app to build.
The universal bundle will be created from this module.
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
imports: [
ServerModule,
AppModule
],
bootstrap: [AppComponent]
})
export class AppServerModule { }
main-ssr.ts
Create a new file src/main-ssr.ts
.
This defines how we export the app module
export { AppServerModule } from './app/app.server.module';
main-ssr ts config
In order to convert the typescript file above we need a matching ts config. This will extend existing config used by the client side. An important change is that we use commonjs module, since that is what node js uses.
- Create new file
src/tsconfig.server.json
- add the content
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsr/app",
"baseUrl": ".",
"module": "commonjs",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "app/app.server.module#AppServerModule"
}
}
Tell Angular CLI how to build server app
Open the .angular-cli.json
.
This fle contains an array of applications. Currently there is only one (the client side app).
We are going to add another one for the server side app.
Add this entry to the array.
{
"platform": "server",
"root": "src",
"outDir": "functions/dist-server",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main-ssr.ts",
"test": "test.ts",
"tsconfig": "tsconfig.ssr.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"styles": [
"styles.css"
],
"scripts": [],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
Build both versions of the app
Build both versions of the application.
Browser
ng build --prod
(this creates content indist
folder)
Server (this creates content in dist-server
folder)
ng build -aot -app ssr --output-hashing none
Note: we use
--output-hashing none
since these hashes are used for client side caching and are not needed server side. It also means we don't need to keep track of the hashid when referencing the main.bundle in later steps.
Create function to render initial page
We need to ensure that the server side app has the same dependencies as the browser app.
- update
functions/package.json
, by adding the following:
"@angular/animations": "^5.2.0",
"@angular/common": "^5.2.0",
"@angular/compiler": "^5.2.0",
"@angular/core": "^5.2.0",
"@angular/forms": "^5.2.0",
"@angular/http": "^5.2.0",
"@angular/platform-browser": "^5.2.0",
"@angular/platform-browser-dynamic": "^5.2.0",
"@angular/router": "^5.2.0",
"core-js": "^2.4.1",
"rxjs": "^5.5.6",
"zone.js": "^0.8.19"
- cd into
functions
andnpm install
(ornpm --prefix functions install
)
Create Express server
This express server is going to handle all requests and return our server side rendered application.
Copy the following into functions/index.js
require('zone.js/dist/zone-node');
const functions = require('firebase-functions');
const express = require('express');
const path = require('path')
// Import renderModuleFactory from @angular/platform-server.
const renderModuleFactory = require('@angular/platform-server').renderModuleFactory;
// Import the AOT compiled factory for your AppServerModule.
const AppServerModuleNgFactory = require('./dist/main.bundle').AppServerModuleNgFactory;
// Load the index.html file.
const index = require('fs').readFileSync(path.resolve(__dirname, './dist-server/index.html'), 'utf8');
let app = express();
app.get('**', function(req, res) {
renderModuleFactory(AppServerModuleNgFactory, {document: index, url: req.path})
.then(function(html) {
// TODO caching
res.send(html);
}).catch( function(e) {
console.log(e)
});
});
exports.ssr = functions.https.onRequest(app);
Rewrite requests for the index to functions
We need to ensure that all requests get redirected to our function., which we called ssr
.
firebase.json
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"function": "ssr"
}
]
}
}
- copy
dist
folder into todist-server
- delete
dist/index.html
- if we don't delete this firebase hosting serves this intead of calling or SSR function.
Test Locally
firebase serve
At this point, if you open up the hosting URL and inspect the source.
You should see that the <app-root>
element now contains HTML content, something like below.
<app-root _nghost-c0="" ng-version="5.2.10">
<div _ngcontent-c0="" style="text-align:center">
<h1 _ngcontent-c0="">
Welcome to app!
</h1>
<img _ngcontent-c0="" alt="Angular Logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==" width="300">
</div>
<h2 _ngcontent-c0="">Here are some links to help you start: </h2>
<ul _ngcontent-c0="">
<li _ngcontent-c0="">
<h2 _ngcontent-c0=""><a _ngcontent-c0="" href="https://angular.io/tutorial" rel="noopener" target="_blank">Tour of Heroes</a></h2>
</li>
<li _ngcontent-c0="">
<h2 _ngcontent-c0=""><a _ngcontent-c0="" href="https://github.com/angular/angular-cli/wiki" rel="noopener" target="_blank">CLI Documentation</a></h2>
</li>
<li _ngcontent-c0="">
<h2 _ngcontent-c0=""><a _ngcontent-c0="" href="https://blog.angular.io/" rel="noopener" target="_blank">Angular blog</a></h2>
</li>
</ul>
</app-root>
Awesome, SSR in action!
Deploy to Firebase
This is a one liner: firebase deploy
.
Write a script to automate
We don't want to have to copy and paste and delete files manually each time we build. Let's automate!
Create a file called build.js
.
Paste in the following:
const helpers = require('./build.helpers.js');
helpers.copyFolderRecursiveSync('./dist', './functions')
helpers.removeFile('./dist/index.html');
This file defines the work we are going to do on each build
- copy the dist folder to functions
- delete the index file
Now create the file ./build.helpers.js
And paste in the following; this will do the actual work.
const fs = require('fs');
const path = require('path');
function copyFileSync( source, target ) {
var targetFile = target;
//if target is a directory a new file with the same name will be created
if ( fs.existsSync( target ) ) {
if ( fs.lstatSync( target ).isDirectory() ) {
targetFile = path.join( target, path.basename( source ) );
}
}
fs.writeFileSync(targetFile, fs.readFileSync(source));
}
function copyFolderRecursiveSync( source, target ) {
var files = [];
//check if folder needs to be created or integrated
var targetFolder = path.join(target, path.basename( source ) );
if ( !fs.existsSync( targetFolder ) ) {
fs.mkdirSync( targetFolder );
}
//copy all files & folders in directory recursively
if ( fs.lstatSync( source ).isDirectory() ) {
files = fs.readdirSync( source );
files.forEach( function ( file ) {
var curSource = path.join( source, file );
if ( fs.lstatSync( curSource ).isDirectory() ) {
copyFolderRecursiveSync( curSource, targetFolder );
} else {
copyFileSync( curSource, targetFolder );
}
} );
}
}
function removeFile(target) {
//remove file IF it exists
const checkFileExists = s => new Promise(r=>fs.access(s, fs.F_OK, e => r(!e)))
checkFileExists(target)
.then(bool => bool && fs.unlinkSync(target))
}
exports.copyFolderRecursiveSync = copyFolderRecursiveSync;
exports.removeFile = removeFile;
Call the build script post build
Update package.json
"build": "ng build --prod && ng build -prod -app ssr --output-hashing none && node build.js",
Test it out. npm run build
should build both apps and perform the required copy and delete.
Add a deploy script
Update package.json
While we are at it, let's add a deploy script too.
"deploy": "ng build --prod && ng build -prod -app ssr --output-hashing none && node build.js && firebase deploy"
Test it out. npm run deploy
should build both apps and perform the required copy and delete AND deploy to firebase.
Gotchas
when running firebase serve
Error: No NgModule metadata found for 'AppModule'.
Error: No NgModule metadata found for 'function (){}'
Make sure you have specified the correct entry point in angular-cli.json
"main": "main-ssr.ts"
And not
"main": "main.ts"
Error: Unable to authorize access to project
- Delete
firebae.json
andfirebaserc
andfirebase init
Repo
I'll post a repo up shortly