DEV Community

matti-b-kenobi
matti-b-kenobi

Posted on

Node, TypeScript, Azure Web Apps — what could go wrong. 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.

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

Project Layout

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.

Oh hai world

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).

Login

However, our authed routes fail.

Route Auth

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

Authorised Route

And check current user route

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.

Web App

Once setup you can browse to the resource and confirm it is up and running.

Web App

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

Web App Config

Next, we will update the root that the server will look for:

Web App Config


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

DevOp Config

Select your repo and click continue to proceed. The first task to add is to add the correct version of node.

DevOp Config

update the options to select 10.14.1

DevOp Config

Next, we need to add the npm based tasks. Add a new task and select npm and use the following options.

DevOp Config

DevOp Config

DevOp Config

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)

DevOp Config

DevOp Config

DevOp Config

Next, we publish the pipeline artefact

DevOp Config

DevOp Config

Finally, we deploy the pipeline artefact to the Azure Web App

DevOp Config

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

Updated Web App

Going to the browse on the console I attempt to run the node project manually and I see the following error message:

Errors

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

Folder Structure

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.

Web App


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

https://social.msdn.microsoft.com/Forums/sqlserver/en-US/871cc7c7-2917-4c96-b98d-f1e488937b43/azure-website-nodejs-doesnt-run-64-bit?forum=windowsazurewebsitespreview

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/).

Top comments (0)