Skip to content

Instantly share code, notes, and snippets.

@jwickens
Last active January 4, 2018 20:28
Show Gist options
  • Save jwickens/0a6dc0655f756d01d01f7057537b057b to your computer and use it in GitHub Desktop.
Save jwickens/0a6dc0655f756d01d01f7057537b057b to your computer and use it in GitHub Desktop.
ngrok wrapper es6
const request = require('request-promise-native')
const { spawn } = require('child_process')
const { EventEmitter } = require('events')
const platform = require('os').platform()
const uuid = require('uuid')
// const url = require('url')
const path = require('path')
const bin = './ngrok' + (platform === 'win32' ? '.exe' : '')
const ready = /starting web service.*addr=(\d+\.\d+\.\d+\.\d+:\d+)/
const inUse = /address already in use/
// note that this file "index.js" was moved into a src dir..
const binDir = path.join(__dirname, '..', '/bin')
const MAX_RETRY = 100
class NGrok extends EventEmitter {
constructor () {
super()
this.tunnels = {}
}
async connect (opts) {
opts = this.defaults(opts)
this.validate(opts)
if (!this.api) {
await this.runNgrok(opts)
}
return this.runTunnel(opts)
}
defaults (opts) {
opts = opts || {proto: 'http', addr: 80}
if (typeof opts === 'function') {
opts = {proto: 'http', addr: 80}
}
if (typeof opts !== 'object') {
opts = {proto: 'http', addr: opts}
}
if (!opts.proto) {
opts.proto = 'http'
}
if (!opts.addr) {
opts.addr = opts.port || opts.host || 80
}
if (opts.httpauth) {
opts.auth = opts.httpauth
}
return opts
}
validate (opts) {
if (opts.web_addr === false || opts.web_addr === 'false') {
throw new Error('web_addr:false is not supported, module depends on internal ngrok api')
}
}
async runNgrok (opts) {
const start = ['start', '--none', '--log=stdout']
if (opts.region) {
start.push('--region=' + opts.region)
}
if (opts.configPath) {
start.push('--config=' + opts.configPath)
}
this.ngrok = spawn(bin, start, {cwd: binDir})
let resolvePromise, rejectPromise
const promise = new Promise((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
this.ngrok.stdout.on('data', (data) => {
const msg = data.toString()
const addr = msg.match(ready)
if (addr) {
this.api = request.defaults({baseUrl: 'http://' + addr[1]})
resolvePromise()
} else if (msg.match(inUse)) {
rejectPromise(new Error(msg.substring(0, 10000)))
} else {
console.warn('not sure when this happens in ngrok')
}
})
this.ngrok.stderr.on('data', (data) => {
const info = data.toString().substring(0, 10000)
rejectPromise(new Error(info))
})
this.ngrok.on('close', () => {
this.emit('close')
})
process.on('exit', async () => {
await this.kill()
})
try {
const response = await promise
return response
} finally {
this.ngrok.stdout.removeAllListeners('data')
this.ngrok.stderr.removeAllListeners('data')
}
}
async runTunnel (opts, retryCount = 0) {
opts.name = String(opts.name || uuid.v4())
try {
const response = await this.api.post({url: 'api/tunnels', json: opts})
const publicUrl = response.public_url
if (!publicUrl) {
throw new Error(response.msg || 'failed to start tunnel')
}
this.tunnels[publicUrl] = response.uri
if (opts.proto === 'http' && opts.bind_tls !== false) {
this.tunnels[publicUrl.replace('https', 'http')] = response.uri + ' (http)'
}
return publicUrl
} catch (err) {
const body = err.response.body
const notReady500 = err.statusCode === 500 && /panic/.test(body)
const notReady502 = err.statusCode === 502 && body.details && body.details.err === 'tunnel session not ready yet'
if ((notReady500 || notReady502) && retryCount < MAX_RETRY) {
await new Promise((resolve) => setTimeout(resolve, 200))
retryCount++
return this.runTunnel(opts, retryCount)
}
throw err
}
}
async authtoken (token, configPath) {
const authtoken = ['authtoken', token]
if (configPath) {
authtoken.push('--config=' + configPath)
}
const a = spawn(
bin,
authtoken,
{cwd: binDir})
const promise = new Promise((resolve, reject) => {
a.stdout.once('data', () => {
a.kill()
resolve(token)
})
a.stderr.once('data', () => {
a.kill()
reject(new Error('cant set authtoken'))
})
})
return promise
}
async disconnect (publicUrl) {
if (!this.api) {
return
}
if (publicUrl) {
await this.api.del(this.tunnels[publicUrl])
delete this.tunnels[publicUrl]
this.emit('disconnect', publicUrl)
return
}
for (const url in Object.keys(this.tunnels)) {
await this.disconnect(url)
}
this.emit('disconnect')
}
async kill () {
if (!this.ngrok) {
return
}
let resolvePromise
const promise = new Promise((resolve) => {
resolvePromise = resolve
})
this.ngrok.on('exit', () => {
this.api = null
this.tunnels = {}
this.emit('disconnect')
resolvePromise()
})
this.ngrok.kill()
return promise
}
}
module.exports = NGrok
@jwickens
Copy link
Author

jwickens commented Jan 4, 2018

Hey @bubenshchykov before i submit this as a PR just had a few questions about how to proceed:

A) "ui url"
The uiUrl parsed here - https://github.com/bubenshchykov/ngrok/blob/4a6d1c656ee1bd825a17e6801b3398ebdaaf2136/index.js#L188

What is that? How should we return it as on here in this gist i return just the publicUrl when you await ngrok.connect the alternative i thought of was to return an object instead but that doesnt seem really neat.

B) auth token
The authToken method here https://gist.github.com/jwickens/0a6dc0655f756d01d01f7057537b057b#file-index-js-L146

I couldnt figure out in the original code how this works, how the auth token makes it back in. In fact this method is never called by the connect method here in the gist.

(C) node.js version supported

Please note that request-promise-native requires node greater than 0.12 (https://github.com/request/request-promise-native/blob/1874877850a59152915c9e9cbacbdc577486cca5/package.json#L33)

The async/await, classes and destructuring runs fine natively on node.js >= 8.3

For versions below that babel can be used, however that comes at the cost of making the code less easily inspectable from node_modules depending on how low you want to go.

So what do you want to target?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment