Node, TypeScript, Azure Web Apps — what could go wrong. A lessons learnt guide
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.
EDIT: 09/03/2021 Updated some of the code snippets to align with a more current state of the GitHub project.
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 bcrypt reflect-metadata routing-controllers jsonwebtoken winston --save
Next, install the typescript dependencies
npm install typescript tslint ts-node -save-dev
And finally the types
npm install @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 { createExpressServer, Action } from 'routing-controllers';
const { createLogger, format, transports } = require('winston');
import jwt = require('jsonwebtoken');// Express Server Setup
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 3000;
const jwtKey = process.env.JWTKEY || 'complexKey';
const logDir = process.env.LOGDIR || './log';
const environment = process.env.ENV || 'development'
const useProd = environment === 'production' ? true : false;// Setup Server
const app = createExpressServer({
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
middlewares: [],
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 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
export * from './index.controller';
Finally, we need to add the controllers to our main program file.
/src/index.ts
import 'reflect-metadata'; // this shim is required
import { createExpressServer, Action } from 'routing-controllers';
const { createLogger, format, transports } = require('winston');
import jwt = require('jsonwebtoken');// TOTO: Project Imports
import { IndexController } from './controller';// Express Server Setup
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 3000;
const jwtKey = process.env.JWTKEY || 'complexKey';
const logDir = process.env.LOGDIR || './log';
const environment = process.env.ENV || 'development'
const useProd = environment === 'production' ? true : false;// Setup Server
const app = createExpressServer({
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
middlewares: [],
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 illstrative 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 Setup
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 3000;
const jwtKey = process.env.JWTKEY || 'complexKey';
const logDir = process.env.LOGDIR || './log';
const environment = process.env.ENV || 'development'
const useProd = environment === 'production' ? true : false;// Setup Server
const app = createExpressServer({
errorOverridingMap: {
ForbiddenError: {
message: 'Access is denied'
}
},
middlewares: [],
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 });
}
});
});
}
Making the updated index.ts look like this:
import 'reflect-metadata'; // this shim is required
import { createExpressServer, Action } from 'routing-controllers';
const { createLogger, format, transports } = require('winston');
import jwt = require('jsonwebtoken');
// Project Imports
import { IndexController, LoginController } from './controller';// Express Server Setup
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 3000;
const jwtKey = process.env.JWTKEY || 'complexKey';
const logDir = process.env.LOGDIR || './log';
const environment = process.env.ENV || 'development'
const useProd = environment === 'production' ? true : false;// Setup Server
const app = createExpressServer({
cors: useProd,
development: useProd,
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;
},
middlewares: [],
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 let's test again
And check the current user route
Secure with Middleware
Before we complete this, lets add some additional security to the web app (we are deploying to the public cloud after all). For this, we will use the helmet middleware. Because we are using the createExpressServer method we will need to wrap this in a Middleware.
Create a new file called helmet.middleware.ts here /src/middleware
import helmet from "helmet";
import { ExpressMiddlewareInterface, Middleware } from "routing-controllers";@Middleware({ type: 'before' })
export class HelmetMiddleware implements ExpressMiddlewareInterface {
public use(request: any, response: any, next?: (err?: any) => any): any {
return helmet()(request, response, next);
}
}
Like the controllers, we want to create an index folder (/src/middleware/index.ts) (gives us some scope to scale):
export * from './helmet.middleware';
Finally, we will update the index and import our new middleware
import 'reflect-metadata'; // this shim is required
import { createExpressServer, Action } from 'routing-controllers';
const { createLogger, format, transports } = require('winston');
import jwt = require('jsonwebtoken');// Project Imports
import { IndexController, LoginController } from './controller';
import { HelmetMiddleware } from './middleware';// Express Server Setup
const loglevel = process.env.LOGLEVEL || 'info';
const port = process.env.PORT || 3000;
const jwtKey = process.env.JWTKEY || 'complexKey';
const logDir = process.env.LOGDIR || './log';
const environment = process.env.ENV || 'development'
const useProd = environment === 'production' ? true : false;// Setup Server
const app = createExpressServer({
cors: useProd,
development: useProd,
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;
},
middlewares: [HelmetMiddleware],
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 });
}
});
});
}
With all that done — let's prepare to deploy this to Azure Web App
Deploying to Azure Web App
Create Web App
So let's set up the Azure Web App. Do this is a straightforward 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:
That is all we need to do to the Azure web app for now.
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 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, 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, add the npm based tasks. Add a new task and select npm and use the following options.
Next, package both the web.config files and our application together. For this 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, publish the pipeline artifact
Finally, deploy the pipeline artefact to the Azure Web App
With all that done queue up a build and confirm that everything does.
Troubleshooting
Having successfully built out project out and go over to our Azure Web app and browse and you might see the following
Going to the console on Azure 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, 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.
Conclusion time
First time playing through — oh my word what a bunch of faff. The issue was mainly that there are so many parts of this and no one location for an answer (instead there are four or five). 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:
In the end, I hope this helps the next person who comes along looking to find out the answer to this.