Node, TypeScript, Azure Web Apps — what could go wrong. Lessons learnt guide
matti-b-kenobi
Posted on August 14, 2019
I have been a convert to Typescript — it was introduced to me via Angular and now I am introducing it to my workflow in the Node.js world.
Recently I have been building a Node.JS project and deploying this through to an Azure Web App — Why Azure Web App, well I have an MSDN and I have some credit so I thought why not.
In this post, I am going to build a basic express application using TypeScript, we will build out a couple of controllers (with some basic auth) and then we will use Microsofts DevOps tools to build our server and deploy.
So why am I doing this- well along the way I hit snags and annoyances and so that other people don't fall down the same potholes I thought I would point out the elements of the pain along the way.
Warning: This is a very long post. To make it short there is a TLDR.
TLDR
I will go through the basic setup of this project end to end I won't bore you — so here are the high-level points
- Using TypeScript is awesome 😍
- If you are deploying to Azure Web Apps use port 1377 🤔
- In this project, I have used bcrypt for my password salting and hashing. Because the build tools will build using node 64-bit node your project won't work when it is deployed as it is not a 32-bit app. And Azure Web App only supports 32bit node. In that case, you will need to ship your own version of node 😤
- Your typescript will build to a dist folder, the web.config will need to be placed in there as part of your DevOps build 😃
- Using the Publish Pipeline Artifact will reliably upload your work to the WebApp (it won't time out like FTP upload will) ✨
- Git repo is here https://github.com/anvilation/azure-webapp-typescript-express
The Setup
Node Version
For this project, I am using Node Version 10.14.1. Whilst it is not super critical for this project I know it will match through to the version that I will deploy on Azure (because of the various versions that I play with depending on the project I tend to use NVS to switch up my node versions).
Layout
The folder structure that we will be using for this is very simple
Packages
Let's get started by installing the following packages. The first is the packages that we are going to use to build this app out:
npm install express helmet body-parser bcrypt reflect-metadata routing-controllers jsonwebtoken --save
Next, install the typescript dependencies
npm install typescript tslint ts-node -save-dev
And finally the types
npm install @types/body-parser @types/express @types/helmet @types/bcrypt @types/jsonwebtoken --save-dev
TypeScript
Next we setup TypeScript — now there are a bunch of ways to set this up — but for me, I tend to use the same as the previous projects however you can get away by simply using
tslint --init
This will create a tslint.json in the root of your project. Finally, we will add a tsconfig.json to the root of this project
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": ["node_modules/*"]
},
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/**/*"]
}
Again — there will be a bunch of options that you may need to add and I am simply bringing forward config from previous projects.
package.json
To complete the setup we want to add some additional scripts to the package.json
"prebuild": "tslint -c tslint.json -p tsconfig.json --fix",
"build": "tsc",
"dev": "nodemon --watch './src/**/*.ts' --exec ts-node ./src/index.ts",
Let's build
Let's start with index.ts. Now there is a bunch going on in the code below. Consider this more boilerplate and we will add in details as we go.
import 'reflect-metadata'; // this shim is required
import { useExpressServer, Action } from 'routing-controllers';
import express from 'express';
import helmet from 'helmet';
import * as bodyParser from 'body-parser';
import jwt = require('jsonwebtoken');
// TODO: Controllers
// Express Server
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 1337;
const app = express();
// Setup for Production Environment
if (process.env.ENV !== 'development') {
app.set('trust proxy', true);
app.use(helmet());
app.disabled('x-powered-by');
}
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Start Server using useExpressServer
useExpressServer(app, {
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
controllers: []
});
app.listen(port, () => {
logger.log(
{
level: 'info',
message: `SERVER: Server running on: ${port}`
}
);
});
From the code above key points are:
- the port is set to 1337
- We are using the (router-controller)[https://github.com/typestack/routing-controllers] module and using that in conjunction with ExpressJS
- Current there are no controllers configured so this server won't return any data
First Controller
Let's return some data by creating our first controller.
src/controller/index.controller.ts
import { Controller, Get, Req, Res } from 'routing-controllers';
@Controller()
export class IndexController {
@Get('/')
getApi(@Req() request: any, @Res() response: any) {
return response.send('<h1>Oh hai world</h1>');
}
}
As we may build many controllers we will create an index file on the controllers.
src/controller/index.ts
import 'reflect-metadata'; // this shim is required
import { useExpressServer, Action } from 'routing-controllers';
import express from 'express';
import helmet from 'helmet';
import * as bodyParser from 'body-parser';
import jwt = require('jsonwebtoken');
// TODO: Controllers
import { IndexController } from './controller';
// Express Server
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 1337;
const app = express();
// Setup for Production Environment
if (process.env.ENV !== 'development') {
app.set('trust proxy', true);
app.use(helmet());
app.disabled('x-powered-by');
}
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Start Server using useExpressServer
useExpressServer(app, {
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
controllers: [ IndexController ]
});
app.listen(port, () => {
logger.log(
{
level: 'info',
message: `SERVER: Server running on: ${port}`
}
);
});
To run, from the console type in npm run dev and confirm that the browser returns a response.
Authorised Controller
Next, we will create a controller that will authorise a user. This controller will have three methods:
- login (to enable people to login)
- routewithauth (a route that allows authorised users to access)
- routhwithcurrentuser (a route that is accessible for the current user)
src/controller/auth.controller.ts
import { JsonController, Post, BodyParam, NotAcceptableError, Authorized, CurrentUser, Req, Res, UnauthorizedError, Get } from 'routing-controllers';
import jwt = require('jsonwebtoken');
import bcrypt from 'bcrypt';
/*
BIG FAT WARNING
I am using static usernames and passwords here for illustrative purposes only
*/
@JsonController()
export class LoginController {
private user = { name: 'user', password: 'muchcomplex' };
jwtKey = process.env.JWTKEY || 'complexKey';
private saltRounds = 10;
constructor() {
bcrypt.genSalt(this.saltRounds, (err: Error, salt: string) => {
bcrypt.hash(this.user.password, salt, (hashErr: Error, hash: string) => {
this.user.password = hash;
});
});
}
@Post('/login')
login(@BodyParam('user') user: string, @BodyParam('pass') pass: string) {
if (!user || !pass) {
// No data supplied
throw new NotAcceptableError('No Email or Password provided');
} else if (user !== this.user.name) {
// No data supplied
throw new NotAcceptableError('Username Incorrect');
} else {
return new Promise<any>((ok, fail) => {
bcrypt.compare(pass, this.user.password, (err: Error, result: boolean) => {
if (result) {
const token = jwt.sign({exp: Math.floor(Date.now() / 1000) + 60 * 60, data: { username: this.user.name }
}, this.jwtKey);
ok({ token: token }); // Resolve Promise
} else {
fail(new UnauthorizedError('Password do not match'));
}
});
});
}
}
@Authorized()
@Get('/routewauth')
authrequired(@Req() request: any, @Res() response: any) {
return response.send('<h1>Oh hai authorised world</h1>');
}
@Authorized()
@Get('/routewacurrentuser')
updatepass( @CurrentUser({ required: true }) currentuser: any, @Res() response: any ) {
return response.send(`<h1>Oh hai ${currentuser.user} world</h1>`);
}}
As with the index.controller we add the additional controller:
src/controller/index.ts
export * from './index.controller';
export * from './auth.controller';
And we add the controller to the index.ts
src/controller/index.ts
import 'reflect-metadata'; // this shim is required
import { useExpressServer, Action } from 'routing-controllers';
import express from 'express';
import helmet from 'helmet';
import * as bodyParser from 'body-parser';
import jwt = require('jsonwebtoken');
// TODO: Controllers
import { IndexController, LoginController } from './controller';
// Express Server
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 1337;
const app = express();
// Setup for Production Environment
if (process.env.ENV !== 'development') {
app.set('trust proxy', true);
app.use(helmet());
app.disabled('x-powered-by');
}
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Start Server using useExpressServer
useExpressServer(app, {
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
controllers: [ IndexController, LoginController ]
});
app.listen(port, () => {
logger.log(
{
level: 'info',
message: `SERVER: Server running on: ${port}`
}
);
});
Restart the server and let's check that the new controller works (I am using postman for this).
However, our authed routes fail.
So let's fix that. To do that we will need to make some adjustments to our main program:
We will add the JWT key:
const jwtKey = process.env.JWTKEY || ‘complexKey’;
We will add an authorisation checker param to our useExpressServer command
authorizationChecker: async (action: Action) => {
const token = action.request.headers['authorization'];
let check: boolean;
jwt.verify(token, process.env.JWTKEY, (error: any, sucess: any) =>
{
if (error) {
check = false;
} else {
check = true;
}
});
return check;
}
And we will add a currentUserCheck. This will check the token and return some current user information. This comes in two parts — param in the useExpressServer command and an async function that returns the user information. I separate these as there may be additional checks that you might want to do if you scale this out to use a DB instance.
currentUserChecker: async (action: Action) => {
const token = action.request.headers['authorization'];
const check = confirmUser(token);
return check;
},
The confirmUser method
async function confirmUser(token: any) {
return await new Promise((ok, fail) => {
jwt.verify(token, process.env.JWTKEY, (error: any, success: any) => {
if (error) {
fail({ user: null, currentuser: false });
} else {
ok({ user: success.data.username, currentuser: true });
}
});
});
}
import 'reflect-metadata'; // this shim is required
import { useExpressServer, Action } from 'routing-controllers';
import express from 'express';
import helmet from 'helmet';
import * as bodyParser from 'body-parser';
import jwt = require('jsonwebtoken');
// TODO: Controllers
import { IndexController, LoginController } from './controller';
// Express Server
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 1337;
const jwtKey = process.env.JWTKEY || 'complexKey';
const app = express();
// Setup for Production Environment
if (process.env.ENV !== 'development') {
app.set('trust proxy', true);
app.use(helmet());
app.disabled('x-powered-by');
}
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Start Server using useExpressServer
useExpressServer(app, {
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
authorizationChecker: async (action: Action) => {
const token = action.request.headers['authorization'];
let check: boolean;
jwt.verify(token, jwtKey, (error: any, sucess: any) => {
if (error) { check = false; } else { check = true; }
});
return check;
},
currentUserChecker: async (action: Action) => {
const token = action.request.headers['authorization'];
const check = confirmUser(token);
return check;
},
controllers: [ IndexController, LoginController ]
});
app.listen(port, () => {
logger.log(
{
level: 'info',
message: `SERVER: Server running on: ${port}`
}
);
});
async function confirmUser(token: any) {
return await new Promise((ok, fail) => {
jwt.verify(token, jwtKey, (error: any, success: any) => {
if (error) {
fail({ user: null, currentuser: false });
} else {
ok({ user: success.data.username, currentuser: true });
}
});
});
}
Now lets test again
And check current user route
With all that done — lets prepare to deploy this to Azure Web App
Deploying to Azure Web App
Create Web App
So lets set up the Azure Web App. Do this is a straight forward process of adding a new WebApp. For this walkthrough, I have changed the plan to the free service plan.
Once setup you can browse to the resource and confirm it is up and running.
Before we go let's make a quick change to the environment variables here. Browse to application settings and update the node version; to do this browse to the Application Settings and add a new setting WEBSITE_NODE_DEFAULT_VERSION to 10.14.1
Next, we will update the root that the server will look for:
Ready Node project for Azure Deployment
Back to our project and we are going to add two new files to out setup:
- web.config
Web.config
This is based upon the IISNode (https://github.com/tjanczuk/iisnode) project. This allows you to run a NodeJS project on IIS (which is the application server on the Azure Web App).
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<webSocket enabled="false" />
<handlers>
<add name="iisnode" path="index.js" verb="*" modules="iisnode" />
</handlers>
<iisnode />
<rewrite>
<rules>
<rule name="NodeInspector" patternSyntax="ECMAScript" stopProcessing="true">
<match url="^index.js\/debug[\/]?" />
</rule>
<rule name="StaticContent">
<action type="Rewrite" url="public{REQUEST_URI}"/>
</rule>
<rule name="DynamicContent">
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="True"/>
</conditions>
<action type="Rewrite" url="index.js"/>
</rule>
</rules>
</rewrite>
<security>
<requestFiltering>
<hiddenSegments>
<remove segment="bin"/>
</hiddenSegments>
</requestFiltering>
</security>
<httpErrors existingResponse="PassThrough" />
</system.webServer>
</configuration>
With all the information committed we are ready to build the Azure DevOps pipeline.
Azure DevOps
There are a number of ways to deploy to an Azure Web App — but for this exercise will use Azure DevOps (https://azure.microsoft.com/en-au/services/devops/), it is included and it pretty simple to set up with some build Azure friendly functions that we can take advantage of.
Now again — there are a ton of options with this service including the option of using it as a git like repo — but we only need it for the build for this project so that is what we will use it for.
At a high level our build will:
- use the correct version of node
- install global dependencies (typescript and the like)
- install project dependencies
- build the server
- package the files for deploy to the Azure Web Service
- deploy the files to the Azure Web Service
To create a build, select Pipelines > Builds and create a new build
Select your repo and click continue to proceed. The first task to add is to add the correct version of node.
update the options to select 10.14.1
Next, we need to add the npm based tasks. Add a new task and select npm and use the following options.
Next, we need to package both the web.config files and our application together. I have chosen to do this in two steps — this is to allow me to create larger mono projects that include a web client and I will build the web client into the final build.
So go ahead and two new tasks (Copy Files)
Next, we publish the pipeline artefact
Finally, we deploy the pipeline artefact to the Azure Web App
With all that done we can then queue up a build and confirm that everything does.
Troubleshooting
We have successfully built out project out and we have gone over to our Azure Web app and browse and see the following
Going to the browse on the console I attempt to run the node project manually and I see the following error message:
Turns out that Azure web apps do not support 64 but node. There are workarounds here — you can deploy a container, or you can do what has been suggested on the MSDN boards and deploy your own version of node.
In our project create a new folder called bin and copy the node.exe there
Next, we need to ensure that the project will run using the correct version of node. For this, we need to update the web.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<webSocket enabled="false" />
<handlers>
<add name="iisnode" path="index.js" verb="*" modules="iisnode" />
</handlers>
<iisnode nodeProcessCommandLine="d:\home\site\wwwroot\bin\x64\node.exe"/>
<rewrite>
<rules>
<rule name="NodeInspector" patternSyntax="ECMAScript" stopProcessing="true">
<match url="^index.js\/debug[\/]?" />
</rule>
<rule name="StaticContent">
<action type="Rewrite" url="public{REQUEST_URI}"/>
</rule>
<rule name="DynamicContent">
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="True"/>
</conditions>
<action type="Rewrite" url="index.js"/>
</rule>
</rules>
</rewrite>
<security>
<requestFiltering>
<hiddenSegments>
<remove segment="bin"/>
</hiddenSegments>
</requestFiltering>
</security>
<httpErrors existingResponse="PassThrough" />
</system.webServer>
</configuration>
Commit these changes and then re-run your DevOps build pipeline.
First time playing through — oh my word what a bunch of faff. The issue was mainly that in so many parts of this there is not just one location for an answer there are four or fix. In the end of a lot of the faff would be cut out if Azure Web App would support 64-bit node — there are definitely some people asking for this:
https://github.com/Azure/app-service-announcements/issues/22
In the end, I hope this helps the next person who comes along looking to find out the answer to this.
Connect with Driver Lane on Twitter (https://twitter.com/driverlane_au), and LinkedIn (https://www.linkedin.com/company/driver-lane/), or directly on our website (https://www.driverlane.com.au/).
Posted on August 14, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 14, 2019