I come across ngrok more and more every day; it provides an excellent HTTP tunnel from my local computer to the internet. I use it to debug Slack apps locally, so I would like to integrate it with my Node.js application.

The free version generates a new sub domain every time you connect, that’s why I’m usually start ngrok at the beginning of my day. I’ve tried to use the ngrok npm package in my application, but as the documentation says: “The ngrok and all tunnels will be killed when node process is done.” I need the process to “survive” my application. Let’s see what we can do about that…

Process

One picture says more than a 1000 words, so I created a flow-diagram to show what we are trying to do:

Diagram for starting NGROK in detached mode.

We will start the ngrok process in detached mode, so it will survive the shutdown of our application. This is especially important, when you run a nodemon that restarts the application on every save.

Dependencies

I’m using 2 packages to make this work:

npm install axios --save-dev
npm install fkill --save-dev
npm install ngrok --save-dev

The ngrok package ships the ngrok binary, which will launch the ngrok tunnel. The axios package is used to retrieve the ngrok URL. The fkill package allows us to stop expired tunnels by killing the ngrok process.

Configuration

Add a ngrok configuration yaml file named ngrok.yml to the root of your application and fill it like this:

tunnels:
  app:
    proto: http  # exposes an HTTP end point
    addr: 3000   # uses local port 3000

We also need to add an environment setting: NGROK=true. Production environments will not have this variable set, so ngrok is not started.

The script

This file lives in my root directory and is called ngrok.js.

// imports
const fs = require('fs');
const path = require('path');
const { platform } = require('os');
const { spawn } = require('child_process');

// settings
const pollInterval = 500;
const ngrokConfig = path.resolve('ngrok.yml');

// needed for spawning NGROK
let ngrokBin = '';
let ngrokDir = '';
let ngrokProc = '';
try {
    const ext = platform() === 'win32' ? '.exe' : '';
    ngrokDir = path.dirname(require.resolve('ngrok')) + '/bin';
    ngrokProc = 'ngrok' + ext;
    ngrokBin = ngrokDir + '/' + ngrokProc;
}
catch { }

async function ensureConnection(callback) {

  if (!ngrokEnabled) return false;

  if (!fs.existsSync(ngrokConfig)) {
    console.log(`Can't run ngrok - missing ${ngrokConfig}.`);
    return false;
  }

  if (ngrokBin == '') {
    console.log("Can't run ngrok - are dev dependencies installed?");
    return false;
  }

  console.log("Ensuring ngrok...");
  const url = await connect();
  if (url == null) return false;

  callback(url);
  return true;
}

async function connect() {
  let url = await getNgrokUrl();
  if (url) {
    console.log("ngrok already running.");
    return url;
  }

  console.log("Starting ngrok...");
  await startProcess();

  while (true) {
    url = await getNgrokUrl();
    if (url) return url;
    await delay(pollInterval);
  }
}

async function getNgrokUrl() {
    const axios = require('axios');
    const ping = 'http://127.0.0.1:4040/api/tunnels';
    let url = "";
    try {
        const response = await axios.get(ping);
        url = response.data.tunnels[0].public_url;
        if (url.startsWith("http://")) {
            url = "https://" + url.substr("http://".length);
        }
    }
    catch (ex) {
        return null;
    }
    try {
        await axios.get(url);
    }
    catch (ex) {
        if (ex && ex.response && ex.response.status == "402") {
            console.log("Killing expired tunnel...");
            stopProcess();
            await delay(2000);
            return null;
        }
    }
    return url;
}

function startProcess() {
  const start = ['start', '-config=' + ngrokConfig, "app"];
  const proc = spawn(ngrokBin, start, { cwd: ngrokDir, detached: true });
  proc.unref();
}

function stopProcess() {
    const fkill = require('fkill');
    fkill(ngrokProc, { force: true });
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

module.exports.ensureConnection = ensureConnection;
module.exports.getNgrokUrl = getNgrokUrl;

Usage

Here is how I use it with a simple express server:

const express = require('express');
const { ensureConnection } = require('./ngrok');
const port = process.env.PORT || 3000;

const app = express();
const server = createServer(app);
server.listen(port, async () => {
  console.log(`Listening on port ${server.address().port}`);
  await ensureConnection(url => {
    console.log(`Listerning to ${url}`);
  });
});

Terminate ngrok background process

On Windows we can manually terminate ngrok like this:

Taskkill /IM ngrok.exe /F

On Mac and Linux you can do:

sudo pkill ngrok

Final thoughts

Ngrok is a wonderful tool to provide HTTP tunnels for local development. I’m looking forward to use it more and more in open source projects. A big shout-out to Arno Jansen for testing and debugging the code in our Slack bots. We’re working on version II of bot-zero, which will have this code out of the box.

Improvements
2020-02-08: only require dev packages when they are absolutely needed.
2020-02-08: environment variables might be loaded in a later stage, so only look for the NGROK variable when needed.
2020-02-08: expired tunnels will be killed using fkill.
2020-02-09: added ngrok kill command for Mac and Linux.