├── .editorconfig
├── .gitignore
├── .npmignore
├── .travis.yml
├── LICENSE
├── README.md
├── example.js
├── index.js
├── lib
└── consul.js
├── package.json
└── test
└── consul.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [Makefile]
12 | charset = utf-8
13 | indent_style = tabs
14 | indent_size = 2
15 | end_of_line = lf
16 | trim_trailing_whitespace = true
17 | insert_final_newline = true
18 |
19 | [*.sh]
20 | insert_final_newline = false
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | npm-debug.log
4 | test/.tmp
5 | *.nar
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | test/.tmp
2 | *.nar
3 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "0.12"
4 | - "iojs"
5 | - "iojs-v1.6.0"
6 |
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) Tomas Aparicio and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person
6 | obtaining a copy of this software and associated documentation
7 | files (the "Software"), to deal in the Software without
8 | restriction, including without limitation the rights to use,
9 | copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the
11 | Software is furnished to do so, subject to the following
12 | conditions:
13 |
14 | The above copyright notice and this permission notice shall be
15 | included in all copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24 | OTHER DEALINGS IN THE SOFTWARE.
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # rocky-consul [](https://travis-ci.org/h2non/rocky-consul) [](https://www.npmjs.org/package/rocky-consul)
2 |
3 | [rocky](https://github.com/h2non/rocky) middleware to easily setup a reverse HTTP proxy with service discovery and load balancer using [Consul](https://consul.io).
4 |
5 | Essentially, this middleware will ask to Consul on every interval (configurable) to retrieve a list of URLs of a specific service (e.g: API, CDN, storage), and then them will be provided to `rocky` in order to balance the incoming HTTP traffic between those URLs.
6 |
7 |
8 |
9 | Name | consul |
10 |
11 |
12 | Rocky | +0.2 |
13 |
14 |
15 | Scope | global, route |
16 |
17 |
18 | Type | forward / balance |
19 |
20 |
21 |
22 | ## Installation
23 |
24 | ```
25 | npm install rocky-consul --save
26 | ```
27 |
28 | ## Usage
29 |
30 | ```js
31 | var rocky = require('rocky')
32 | var consul = require('rocky-consul')
33 |
34 | var proxy = rocky()
35 | ```
36 |
37 | Plug in as global middleware
38 | ```js
39 | proxy.use(consul({
40 | // Servers refresh interval (default to 60000)
41 | interval: 60 * 5 * 1000,
42 | // App service name (required)
43 | service: 'web',
44 | // Use a custom datacenter (optional)
45 | datacenter: 'ams2',
46 | // Consul servers pool
47 | servers: [
48 | 'http://demo.consul.io',
49 | 'http://demo.consul.io'
50 | ]
51 | }))
52 |
53 | // Handle all the traffic
54 | proxy.all('/*')
55 |
56 | proxy.listen(3000)
57 | console.log('Rocky server started')
58 | ```
59 |
60 | Plug in as route level middleware
61 | ```js
62 | proxy
63 | .get('/download/:id')
64 | .use(consul({
65 | // Servers refresh interval (default to 60000)
66 | interval: 60 * 5 * 1000,
67 | // App service name (required)
68 | service: 'web',
69 | // Use a custom datacenter (optional)
70 | datacenter: 'ams2',
71 | // Consul servers pool
72 | servers: [
73 | 'http://demo.consul.io',
74 | 'http://demo.consul.io'
75 | ]
76 | }))
77 |
78 | // Handle the rest of the traffic without using Consul
79 | proxy.all('/*')
80 | .forward('http://my.server')
81 | .replay('http://old.server')
82 |
83 | proxy.listen(3000)
84 | console.log('Rocky server started')
85 | ```
86 |
87 | ## API
88 |
89 | ### consul(options) `=>` Function(req, res, next)
90 |
91 | Return a middleware `function` with the Consul client as static property `function.consul`.
92 |
93 | #### Options
94 |
95 | - **service** `string` - Consul service. Required
96 | - **servers** `array` - List of Consul servers URLs. Required
97 | - **datacenter** `string` - Custom datacenter to use. If not defined the default one will be used
98 | - **tag** `string` - Use a specific tag for the service
99 | - **defaultServers** `array` - Optional list of default target servers to balance. This avoid asking Consul the first time.
100 | - **protocol** `string` - Transport URI protocol. Default to `http`
101 | - **timeout** `number` - Consul server timeout in miliseconds. Default to `5000` = 5 seconds
102 | - **interval** `number` - Consul servers update interval in miliseconds. Default to `120000` = 2 minutes
103 | - **headers** `object` - Map of key-value headers to send to Consul
104 | - **auth** `string` - Basic authentication for Consul. E.g: `user:p@s$`
105 | - **onRequest** `function` - Executes this function before sending a request to Consul server. Passed arguments are: `httpOpts`
106 | - **onUpdate** `function` - Executes this function on every servers update. Passed arguments are: `err, servers`
107 | - **onResponse** `function` - Executes this function on every Consul server response. Passed arguments are: `err, servers, res`
108 |
109 | ### Consul(options)
110 |
111 | Internally used micro Consul client interface.
112 |
113 | #### consul#servers(cb)
114 |
115 | Returns the Consul servers for the given service.
116 | Passed arguments to the callback are: `cb(err, servers)`.
117 |
118 | #### consul#update(cb)
119 |
120 | Perform the servers update asking to Consul
121 | Passed arguments to the callback are: `cb(err, servers)`.
122 |
123 | #### consul#startInterval()
124 |
125 | Start the servers update interval as recurrent job for the given miliseconds defined at `options.interval`.
126 | You should not call this method unless you already called `stopInterval()`.
127 |
128 | #### consul#stopInterval()
129 |
130 | Stop server update interval process.
131 |
132 | ## License
133 |
134 | MIT - Tomas Aparicio
135 |
--------------------------------------------------------------------------------
/example.js:
--------------------------------------------------------------------------------
1 | var rocky = require('rocky')
2 | var consul = require('./')
3 |
4 | var proxy = rocky()
5 |
6 | // Plug in the middleware
7 | proxy.use(consul({
8 | // Servers refresh interval
9 | interval: 10000,
10 | // App service name (required)
11 | service: 'web',
12 | // Use a custom datacenter (optional)
13 | datacenter: 'ams2',
14 | // Consul servers pool
15 | servers: [
16 | 'http://demo.consul.io',
17 | 'http://demo.consul.io'
18 | ]
19 | }))
20 |
21 | // Plugin another middleware at route level only
22 | var route = proxy.get('/download')
23 |
24 | route.use(consul({
25 | // Servers refresh interval
26 | interval: 10000,
27 | // App service name (required)
28 | service: 'web',
29 | // Use a custom datacenter (optional)
30 | datacenter: 'ams2',
31 | // Consul servers pool
32 | servers: [
33 | 'http://demo.consul.io',
34 | 'http://demo.consul.io'
35 | ]
36 | }))
37 |
38 | // Handle all the traffic
39 | proxy.get('/*')
40 |
41 | proxy.listen(3000)
42 |
43 | console.log('Rocky server listening on port: ' + 3000)
44 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const assign = require('object-assign')
2 | const Consul = require('./lib/consul')
3 |
4 | const interval = 60 * 2 * 1000 // 2 minutes
5 |
6 | module.exports = exports = function (params) {
7 | var defaults = { interval: interval }
8 | var opts = assign(defaults, params)
9 | var consul = new Consul(opts)
10 |
11 | function middleware(req, res, next) {
12 | consul.servers(function (err, urls) {
13 | if (err || !urls || !urls.length) {
14 | return proxyError(res)
15 | }
16 |
17 | // Expose to rocky the URLs to balance
18 | req.rocky.options.balance = urls
19 |
20 | // Continue with next middleware
21 | next()
22 | })
23 | }
24 |
25 | // Expose the Consul client instance
26 | middleware.consul = consul
27 |
28 | return middleware
29 | }
30 |
31 | // Expose Consul client
32 | exports.Consul = Consul
33 |
34 | function proxyError(res) {
35 | if (res.headersSent) return
36 | var message = 'Proxy error: cannot retrieve servers from Consul'
37 | res.writeHead(502, {'Content-Type': 'application/json'})
38 | res.end(JSON.stringify({ message: message }))
39 | }
40 |
--------------------------------------------------------------------------------
/lib/consul.js:
--------------------------------------------------------------------------------
1 | const request = require('got')
2 | const parseUrl = require('url').parse
3 |
4 | const consulBasePath = '/v1/catalog/service/'
5 | const requiredParams = ['servers', 'service']
6 |
7 | module.exports = Consul
8 |
9 | function Consul(opts) {
10 | this.opts = opts
11 | this.urls = opts.defaultServers || []
12 |
13 | requiredParams.forEach(function (param) {
14 | if (!opts[param]) throw new TypeError('Missing required param: ' + param)
15 | })
16 |
17 | this.updating = false
18 | this.startInterval()
19 | }
20 |
21 | Consul.prototype.servers = function (cb) {
22 | if (this.urls.length) {
23 | return cb(null, this.urls)
24 | }
25 | this.update(cb)
26 | }
27 |
28 | Consul.prototype.update = function (cb) {
29 | var url = permute(this.opts.servers)
30 | cb = cb || function () {}
31 |
32 | this.updating = true
33 | this.request(url, function (err, servers) {
34 | this.updating = false
35 |
36 | if (err || !Array.isArray(servers)) {
37 | return cb(err)
38 | }
39 |
40 | var urls = mapServers(servers, this.opts)
41 | if (!urls || !urls.length) {
42 | return cb(null, this.urls)
43 | }
44 |
45 | this.urls = urls
46 | if (this.opts.onUpdate) {
47 | this.opts.onUpdate(err, urls)
48 | }
49 |
50 | cb(null, urls)
51 | }.bind(this))
52 | }
53 |
54 | Consul.prototype.startInterval = function () {
55 | this.interval = setInterval(function () {
56 | if (!this.updating) this.update()
57 | }.bind(this), this.opts.interval)
58 | }
59 |
60 | Consul.prototype.stopInterval = function () {
61 | if (this.interval) {
62 | clearInterval(this.interval)
63 | }
64 | this.interval = null
65 | }
66 |
67 | Consul.prototype.request = function (url, done) {
68 | var opts = this.opts
69 | var timeout = +opts.timeout || 5000
70 | var path = consulBasePath + opts.service
71 | var targetUrl = url + path
72 |
73 | var query = {}
74 | if (opts.tag) {
75 | query.tag = opts.tag
76 | }
77 | if (opts.datacenter) {
78 | query.datacenter = opts.datacenter
79 | }
80 |
81 | var httpOpts = {
82 | url: targetUrl,
83 | query: query,
84 | timeout: timeout,
85 | auth: opts.auth,
86 | headers: opts.headers
87 | }
88 |
89 | if (this.opts.onRequest) {
90 | this.opts.onRequest(httpOpts)
91 | }
92 |
93 | var handler = responseHandler(done).bind(this)
94 | request(httpOpts.url, httpOpts, handler)
95 | }
96 |
97 | function responseHandler(done) {
98 | return function (err, data, res) {
99 | if (this.opts.onResponse) {
100 | this.opts.onResponse(err, data, res)
101 | }
102 |
103 | if (err || res.statusCode >= 400 || !data) {
104 | return done(err || 'Invalid response')
105 | }
106 |
107 | done(null, JSON.parse(data))
108 | }
109 | }
110 |
111 | function mapServers(list, opts) {
112 | var protocol = opts.protocol || 'http'
113 | var port = protocol === 'https' ? 443 : 80
114 |
115 | return list
116 | .filter(function (s) {
117 | return s && s.Address
118 | })
119 | .map(function (s) {
120 | if (s.ServiceAddress) {
121 | return protocol + '://' + s.ServiceAddress + ':' + (+s.ServicePort || port)
122 | }
123 | return protocol + '://' + s.Address + ':' + (+s.ServicePort || port)
124 | })
125 | }
126 |
127 | function permute(arr) {
128 | var item = arr.shift()
129 | arr.push(item)
130 | return item
131 | }
132 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rocky-consul",
3 | "version": "0.1.1",
4 | "description": "Rocky HTTP proxy middleware for service discovery and balancing using Consul",
5 | "repository": "h2non/rocky-consul",
6 | "author": "Tomas Aparicio",
7 | "license": "MIT",
8 | "keywords": [
9 | "http",
10 | "proxy",
11 | "http-proxy",
12 | "replay",
13 | "rocky",
14 | "consul",
15 | "balacing",
16 | "balancer",
17 | "reactive",
18 | "discovery",
19 | "service"
20 | ],
21 | "engines": {
22 | "node": ">= 0.12"
23 | },
24 | "scripts": {
25 | "test": "./node_modules/.bin/mocha --timeout 2000 --reporter spec --ui tdd test/*"
26 | },
27 | "devDependencies": {
28 | "chai": "^3.0.0",
29 | "mocha": "^2.2.5",
30 | "nock": "^2.7.0",
31 | "rocky": "^0.3.0"
32 | },
33 | "dependencies": {
34 | "got": "^3.3.0",
35 | "object-assign": "^3.0.0"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/test/consul.js:
--------------------------------------------------------------------------------
1 | const nock = require('nock')
2 | const expect = require('chai').expect
3 | const consul = require('..')
4 |
5 | const noop = function () {}
6 |
7 | suite('consul', function () {
8 | var consulResponse = [
9 | {
10 | "Node": "nyc3-worker-1",
11 | "Address": "127.0.0.1",
12 | "ServiceID": "web",
13 | "ServiceName": "web",
14 | "ServiceAddress": "",
15 | "ServicePort": 80
16 | }
17 | ]
18 |
19 | test('valid', function (done) {
20 | nock('http://consul')
21 | .get('/v1/catalog/service/web?')
22 | .reply(200, consulResponse)
23 |
24 | var md = consul({
25 | service: 'web',
26 | servers: ['http://consul']
27 | })
28 |
29 | var req = { rocky: { options: {} } }
30 | var res = { end: noop }
31 |
32 | md(req, res, assert)
33 |
34 | function assert(err) {
35 | expect(err).to.be.undefined
36 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80'])
37 | done()
38 | }
39 | })
40 |
41 | test('invalid params', function (done) {
42 | function missingService() {
43 | consul({ servers: [] })
44 | }
45 |
46 | function missingServers() {
47 | consul({ servers: 'web' })
48 | }
49 |
50 | expect(missingService).to.throw(TypeError)
51 | expect(missingServers).to.throw(TypeError)
52 | done()
53 | })
54 |
55 | test('invalid response', function (done) {
56 | nock('http://consul')
57 | .get('/v1/catalog/service/web?')
58 | .reply(404)
59 |
60 | var req = {}
61 | var res = { end: assertEnd, writeHead: assertHead }
62 |
63 | var md = consul({
64 | service: 'web',
65 | servers: ['http://consul']
66 | })
67 |
68 | md(req, res)
69 |
70 | function assertHead(code, headers) {
71 | expect(code).to.be.equal(502)
72 | expect(headers).to.be.deep.equal({'Content-Type': 'application/json'})
73 | }
74 |
75 | function assertEnd(data) {
76 | expect(data).to.be.match(/Proxy error: cannot retrieve/)
77 | done()
78 | }
79 | })
80 |
81 | test('timeout', function (done) {
82 | nock('http://consul')
83 | .get('/v1/catalog/service/web?')
84 | .delay(2000)
85 | .reply(404)
86 |
87 | var req = {}
88 | var res = { end: assertEnd, writeHead: assertHead }
89 |
90 | var md = consul({
91 | timeout: 100,
92 | service: 'web',
93 | servers: ['http://consul']
94 | })
95 |
96 | md(req, res)
97 |
98 | function assertHead(code, headers) {
99 | expect(code).to.be.equal(502)
100 | expect(headers).to.be.deep.equal({'Content-Type': 'application/json'})
101 | }
102 |
103 | function assertEnd(data) {
104 | expect(data).to.be.match(/Proxy error: cannot retrieve/)
105 | done()
106 | }
107 | })
108 |
109 | test('headers', function (done) {
110 | nock('http://consul')
111 | .get('/v1/catalog/service/web?')
112 | .matchHeader('User-Agent', 'rocky')
113 | .reply(200, consulResponse)
114 |
115 | var req = { rocky: { options: {} } }
116 | var res = {}
117 |
118 | var md = consul({
119 | headers: {
120 | 'User-Agent': 'rocky'
121 | },
122 | service: 'web',
123 | servers: ['http://consul']
124 | })
125 |
126 | md(req, res, assert)
127 |
128 | function assert(err) {
129 | expect(err).to.be.undefined
130 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80'])
131 | done()
132 | }
133 | })
134 |
135 | test('default servers', function (done) {
136 | nock('http://consul')
137 | .get('/v1/catalog/service/web?')
138 | .reply(200, consulResponse)
139 |
140 | var req = { rocky: { options: {} } }
141 | var res = {}
142 |
143 | var md = consul({
144 | service: 'web',
145 | servers: ['http://consul'],
146 | defaultServers: ['http://default'],
147 | interval: 100
148 | })
149 |
150 | md(req, res, assert)
151 |
152 | function assert(err) {
153 | expect(err).to.be.undefined
154 | expect(req.rocky.options.balance).to.be.deep.equal(['http://default'])
155 |
156 | setTimeout(assertInterval, 150)
157 | }
158 |
159 | function assertInterval() {
160 | md(req, res, function () {
161 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80'])
162 | done()
163 | })
164 | }
165 | })
166 |
167 | test('events', function (done) {
168 | nock('http://consul')
169 | .get('/v1/catalog/service/web?')
170 | .reply(200, consulResponse)
171 |
172 | var req = { rocky: { options: {} } }
173 | var res = {}
174 |
175 | var md = consul({
176 | service: 'web',
177 | servers: ['http://consul'],
178 | onRequest: onRequest,
179 | onUpdate: onUpdate,
180 | onResponse: onResponse
181 | })
182 |
183 | md(req, res, assert)
184 |
185 | function onUpdate(err, servers) {
186 | expect(err).to.be.null
187 | expect(servers).to.be.an('array')
188 | expect(servers).to.have.length(1)
189 | expect(servers[0]).to.be.equal('http://127.0.0.1:80')
190 | }
191 |
192 | function onResponse(err, data, res) {
193 | expect(err).to.be.null
194 | expect(data).to.be.a('string')
195 | expect(data).to.match(/Node/)
196 | expect(data).to.match(/Address/)
197 | expect(res.statusCode).to.be.equal(200)
198 | expect(res.headers).to.have.property('content-type').to.match(/application\/json/)
199 | }
200 |
201 | function onRequest(httpOpts) {
202 | expect(httpOpts).to.be.an('object')
203 | expect(httpOpts.url).to.be.equal('http://consul/v1/catalog/service/web')
204 | }
205 |
206 | function assert(err) {
207 | expect(err).to.be.undefined
208 | expect(req.rocky.options.balance).to.be.deep.equal(['http://127.0.0.1:80'])
209 | done()
210 | }
211 | })
212 | })
213 |
--------------------------------------------------------------------------------