Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to deploy a Typescript, NodeJS and Express app to Heroku

I wanted to provide a howto on this as I couldn't find any complete information. I thought that this seemed most appropriate in Stackoverflow documentation. However it has been sunsetted *-) - Sunsetting Documentation (*).

Instead I will write this as a StackOverflow Q&A.

How to deploy a Typescript, NodeJS and Express app to Heroku

like image 759
HankCa Avatar asked Jan 25 '23 11:01

HankCa


1 Answers

I created a project (https://gitlab.com/OehmSmith_Examples/herokumovies) that includes a README describing what needs to be done and I will reproduce that here. As a good StackOverflow practice I will also provide a copy of all the code at the bottom of this post.

Tutorial - Deploy Typescript NodeJS Express app to Heroku

This tutorial will work from https://amenallah.com/node-js-typescript-jest-express-starter/ as the base app. I have no affiliation with that site or the author. I chose it as it is simple and works. It is also an example of good OO Typescript code.

Install typescript

Most tutorials or even the official documentation in https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html say to install typescript globally:

 > npm install -g typescript

Heroku doesn't have a global install of typescript so it needs to be kept locally. The example project does just this:

 > npm i -D nodemon rimraf typescript ts-node ts-jest jest @types/jest @types/node

@types/node

In the case you have pinned your @types/node at an older version you will see something like this error:

~/AppData/Roaming/nvm/v11.15.0/node_modules/typescript/lib/lib.es2015.iterable.d.ts:41:6 - error TS2300: Duplicate identifier 'IteratorResult'.

41 type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
        ~~~~~~~~~~~~~~

  node_modules/@types/node/index.d.ts:170:11
    170 interface IteratorResult<T> { }
                  ~~~~~~~~~~~~~~
    'IteratorResult' was also declared here.

node_modules/@types/node/index.d.ts:170:11 - error TS2300: Duplicate identifier 'IteratorResult'.

170 interface IteratorResult<T> { }
              ~~~~~~~~~~~~~~

~/AppData/Roaming/nvm/v11.15.0/node_modules/typescript/lib/lib.es2015.iterable.d.ts:41:6
    41 type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
            ~~~~~~~~~~~~~~
    'IteratorResult' was also declared here.


Found 2 errors.

From TypeScript: Duplicate identifier 'IteratorResult'. And as per that you need to update your version of @types/node. This was a problem I struck as I was working with older code and wanted to include this discussion of it.

Update port for cloud service

Change the index.ts to be the following instead since the original code hard-coded port 5000:

app.listen(process.env.PORT, () => {
    console.log(`server started on port ${process.env.PORT}`)
});

To allow for this I have added the PORT to the npm scripts, including adding a start:dev so you can run it like Heroku does from the compiled typescript.

    "start:dev": "PORT=5000 node dist/index.js",
    "dev": "PORT=5000 nodemon --exec ts-node src/index.ts --watch src",

Or it can be set in a .env file:

PORT=5000

NPM dependencies

Heroku will NOT install dev dependencies (neither will any other cloud provider). Therefore you need to move some dependencies to the main block. For example, a NestJS application has these as Dev Dependencies and they need to be moved:

 @nestjs/cli

Dummy data

I added this constructor to the MoviesApi.ts:

constructor() {
    // setup some dummy data
    movies.push({
        name: 'Pirates of the caribbean',
        rating: 8.5
    })
    movies.push({
        name: 'Star Wars: A new hope',
        rating: 8.7
    })
}

Heroku

Now deploy to Heroku

  1. Setup an account if you don't already have one and create an app on Heroku
  2. In your terminal:

    heroku login
    heroku create moviesheroku // this needs to be unique
    
  3. You may need to add the returned git url as a remote (check with git remote -v):

    git remote add heroku <git url>
    
  4. Lookup or search for buildpacks with (the next step already specifies those I use):

    • Lookup at: https://devcenter.heroku.com/articles/buildpacks#officially-supported-buildpacks
    • Search with:

      heroku buildpacks:search typescript

  5. Add buildpacks:

    heroku buildpacks:add zidizei/typescript
    heroku buildpacks:add heroku/nodejs
    
  6. Confirm buildpacks:

    heroku buildpacks
    
  7. Commit to your local repository

    git init  // if not already done
    git add --all
    git ci -m "Initial commit.  Test project all setup and should be ready to 'serve' but not yet ready to deploy to heroku"
    
  8. Run with

    npm dev # OR
    npm run start:dev # It depends on your npm scripts
    
  9. Test with postman or similar or run this from the command-line:

    curl http://localhost:5000/movies
    
  10. Test that it transpiles with npm run build

  11. Update the npm scripts so that after installation (npm install) on Heroku, it will build it before attempting to npm run start

    "postinstall": "npm run build"  # Depends on your npm scripts
    
  12. Commit to local repository:

    git add --all
    git ci -m "Now it should deploy, build and run on heroku"
    
  13. Deploy to heroku. It should build and start up.

    git push heroku master
    
  14. Test (assuming the app you heroku created is moviesheroku - adjust accordingly)

    curl https://moviesheroku.herokuapp.com/movies
    

Variations

Procfile

I haven't specified a Procfile telling Heroku anything about the app. Fortunately it creates its own defaults by determining that this is a node + npm app. However you can explicitly define this and will need to perform this action if you have multiple apps or similar. You could add a Procfile to contain (this is the default):

web: npm start

Node and NPM version

Heroku also defaults to using one of the most recent versions of these. You could explicitly set the versions at the top-level in the package.json file like:

 "engines": {
     "node": "10.x",
     "npm": "6.x"
 },

Although if you don't specify a npm version then Heroku will use a sensible default for the version of node.

Concluding thoughts

I had this going in only a couple hours. The main issues I needed to work out is that typescript has to be local, not global. And the buildpacks. The PORT is an issue also though every cloud provider requires the use of process.env.PORT so this was obvious to me.

Azure was a nightmare and took days, but that was mainly because the workplace I was at insisted on using Windows servers. Long story and I won't go in to it.

AWS was so convoluted. I didn't get the instance I had working after trying for a day. however I do need to try again. The app I was trying used the https://tsed.io/ library. Simple Node / Typescript / Express apps should work out quite easily.

(*) - the sunsetting of documentation was a little bit surprising though given it happened over 2 years ago I guess it wasn't something I used. And I always thought that the Q&A was the easiest place for documentation.

Code

.gitignore

    node_modules
    dist
    coverage

.jest.config.js

    module.exports = {
        preset: 'ts-jest',
        testEnvironment: 'node'
    };

package.json

    {
      "name": "movies",
      "version": "1.0.0",
      "description": "Example from https://amenallah.com/node-js-typescript-jest-express-starter/ but then modify and / or show steps for how to deploy this Typescript NodeJS Express RESTful app to Heroku.",
      "main": "index.js",
      "scripts": {
        "build": "rimraf dist && tsc",
        "postinstall": "npm run build",
        "start": "node dist/index.js",
        "start:dev": "PORT=5000 node dist/index.js",
        "dev": "PORT=5000 nodemon --exec ts-node src/index.ts --watch src",
        "test": "jest --watch",
        "coverage": "jest --coverage"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "@types/express": "^4.17.2",
        "@types/jest": "^24.0.25",
        "@types/node": "^13.1.2",
        "jest": "^24.9.0",
        "nodemon": "^2.0.2",
        "rimraf": "^3.0.0",
        "ts-jest": "^24.2.0",
        "ts-node": "^8.5.4",
        "typescript": "^3.7.4"
      },
      "dependencies": {
        "body-parser": "^1.19.0",
        "express": "^4.17.1"
      }
    }

tsconfig.json

    {
      "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "outDir": "dist",
        "sourceMap": false,
        "allowSyntheticDefaultImports": true,
        "baseUrl": ".",
        "paths": {
          "*": [
            "node_modules/",
          ],
          "typings/*": [
            "src/typings/*"
          ]
        },
      },
      "include": [
        "src/**/*.ts"
      ],
      "exclude": [
        "src/test/**/*.spec.ts"
      ]
    }

src/api/MoviesApi.ts

    import IResource from "typings/IResource";

    let movies: object[] = []

    export default class MoviesApi implements IResource {
        constructor() {
            // setup some dummy data
            movies.push({
                name: 'Pirates of the caribbean',
                rating: 8.5
            })
            movies.push({
                name: 'Star Wars: A new hope',
                rating: 8.7
            })
        }

        create(data: any): any {
            movies.push(data)
            return data
        }

        findMany(): any[] {
            return movies;
        }
    }

src/test/api/Movies.spec.ts

    import IResource from '../typings/IResource'
    import MoviesApi from '../api/MoviesApi'

    const moviesApi: IResource = new MoviesApi()

    describe('Movies API', () => {
        it('should create a new movie', () => {
            const movieData: object = {
                name: 'Pirates of the caribbean',
                rating: 8.5
            };

            const movie: object = moviesApi.create(movieData);

            expect(movie).toEqual(movieData)
        })
    });

src/typings/IResource/index.d.ts

    export default interface IResource {
        create(data: any): any
        findMany(): any[]
    }

src/index.ts

    import * as express from 'express'
    import * as bodyParser from 'body-parser'

    import MoviesApi from './api/MoviesApi'

    const app = express();
    const moviesApi = new MoviesApi();

    app.use(bodyParser.json());

    app.post('/movies', (req: express.Request, res: express.Response) => {
        res.json(moviesApi.create(req.body))
    });

    app.get('/movies', (req: express.Request, res: express.Response) => {
        res.json(moviesApi.findMany())
    });

    app.listen(process.env.PORT, () => {
        console.log(`server started on port ${process.env.PORT}`)
    });
like image 179
HankCa Avatar answered Jan 28 '23 23:01

HankCa