├── .dockerignore ├── .travis.yml ├── Dockerfile ├── .gitignore ├── package.json ├── LICENSE ├── lib └── control.js ├── README.md ├── baseswim.js └── test.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "6" 5 | - "7" 6 | env: 7 | - CXX=g++-4.8 8 | addons: 9 | apt: 10 | sources: 11 | - ubuntu-toolchain-r-test 12 | packages: 13 | - g++-4.8 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:6 2 | 3 | # uncomment for dev 4 | # RUN apk update && \ 5 | # apk add make gcc g++ python git 6 | 7 | RUN mkdir /src 8 | ADD package.json /src/ 9 | 10 | WORKDIR /src 11 | 12 | # comment in dev 13 | RUN apk update && \ 14 | apk add make gcc g++ python git && \ 15 | npm install --production && \ 16 | apk del make gcc g++ python git 17 | 18 | # uncomment for dev 19 | # RUN npm install --production 20 | 21 | COPY . /src 22 | 23 | EXPOSE 3000 24 | ENV SWIM_PORT=7799 25 | ENTRYPOINT ["node", "baseswim.js"] 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baseswim", 3 | "version": "1.0.0", 4 | "description": "A base swim node", 5 | "main": "baseswim.js", 6 | "bin": { 7 | "baseswim": "./baseswim.js" 8 | }, 9 | "scripts": { 10 | "test": "standard && tap test.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/mcollina/baseswim.git" 15 | }, 16 | "keywords": [ 17 | "base", 18 | "swim" 19 | ], 20 | "author": "Matteo Collina ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/mcollina/baseswim/issues" 24 | }, 25 | "homepage": "https://github.com/mcollina/baseswim#readme", 26 | "devDependencies": { 27 | "pre-commit": "^1.1.3", 28 | "request": "^2.69.0", 29 | "standard": "^8.5.0", 30 | "tap": "^8.0.0" 31 | }, 32 | "dependencies": { 33 | "concat-stream": "^1.5.2", 34 | "minimist": "^1.2.0", 35 | "network-address": "^1.0.0", 36 | "pino": "^3.0.0", 37 | "swim": "^0.3.1", 38 | "udp-free-port": "^1.0.0", 39 | "xtend": "^4.0.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matteo Collina 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/control.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('http') 4 | const concat = require('concat-stream') 5 | 6 | function control (swim) { 7 | return http.createServer(handle) 8 | 9 | function handle (req, res) { 10 | if (req.url === '/members') { 11 | members(req, res) 12 | } else if (req.url === '/join' && req.method === 'POST') { 13 | join(req, res) 14 | } else { 15 | res.statusCode = 404 16 | res.end('not found\n') 17 | } 18 | } 19 | 20 | function members (req, res) { 21 | const members = swim.members() 22 | members.unshift(swim.membership.local.data()) 23 | res.writeHead(200, { 24 | 'Content-Type': 'application/json' 25 | }) 26 | res.write(JSON.stringify({ members }, null, 2)) 27 | res.end('\n') 28 | } 29 | 30 | function join (req, res) { 31 | req.pipe(concat((peer) => { 32 | peer = peer.toString() 33 | swim.join([peer], (err) => { 34 | if (err) { 35 | res.statusCode = 400 36 | res.end(err.message) 37 | return 38 | } 39 | res.statusCode = 200 40 | res.end() 41 | }) 42 | })).on('err', (err) => { 43 | res.statusCode = 500 44 | res.end(err.message) 45 | }) 46 | } 47 | } 48 | 49 | module.exports = control 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # baseswim 2 | 3 | A base swim node 4 | 5 | ## Install 6 | 7 | ``` 8 | npm i baseswim -g 9 | ``` 10 | 11 | or 12 | 13 | ``` 14 | docker pull matteocollina/baseswim 15 | ``` 16 | 17 | ## Usage 18 | 19 | ``` 20 | baseswim [--http 3000] [--port PORT] [peers...] 21 | ``` 22 | 23 | Each peer is in the form of IP:PORT, like 127.0.0.1:7799 24 | 25 | ### with Docker 26 | 27 | On Linux: 28 | 29 | ``` 30 | docker run -p 3000:3000 -p 7799:7799/udp -d matteocollina/baseswim --host `ip addr show wlan0 | grep -Po 'inet \K[\d.]+'` --http 3000 31 | ``` 32 | 33 | __adjut the host configuration to your own interface/ip address__ 34 | 35 | On Mac: 36 | 37 | ``` 38 | docker run -p 3000:3000 -p 7799:7799/udp -d matteocollina/baseswim --host `ipconfig getifaddr en0` --http 3000 39 | ``` 40 | 41 | __adjut the host configuration to your own interface/ip address__ 42 | 43 | On docker-machine: 44 | 45 | ``` 46 | docker run -p 3000:3000 -p 7799:7799/udp -d matteocollina/baseswim --host `docker-machine ip default` --http 3000 47 | ``` 48 | 49 | If you need to connect it to other peers pass any peer id at the end, 50 | like for the normal usage. 51 | 52 | ## as a module 53 | 54 | ```js 55 | 'use strict' 56 | 57 | const baseswim = require('baseswim') 58 | const id = '127.0.0.1:7799' // replace your ip address 59 | 60 | const swim = baseswim(id, { 61 | http: 3000 // to enable the HTTP endpoints 62 | }) 63 | 64 | swim.on('peerUp', (peer) => console.log(peer)) 65 | swim.on('peerDown', (peer) => console.log(peer)) 66 | ``` 67 | 68 | The swim instance is the same of [swim-js](http://npm.im/swim). 69 | See its README for the API. 70 | 71 | ### constructor options 72 | 73 | If you do not pass any options, `baseswim` will bound to a network 74 | interface (not localhost) and pick a free udp port. 75 | 76 | You can also pass the host and port as parameters: 77 | 78 | ```js 79 | const swim = baseswim({ 80 | host: '127.0.0.1', 81 | port: 7799 82 | }) 83 | ``` 84 | 85 | ## HTTP endpoints 86 | 87 | If enabled by the `--http` flag, baseswim provides two HTTP endpoint to 88 | control the base node. 89 | 90 | ### GET /members 91 | 92 | Provides a list of the current members, output: 93 | 94 | ``` 95 | $ curl `docker-machine ip default`:3000/members 96 | { 97 | "members": [ 98 | { 99 | "host": "192.168.99.100:7799", 100 | "state": 0, 101 | "incarnation": 0 102 | } 103 | ] 104 | } 105 | ``` 106 | 107 | ### POST /join 108 | 109 | Provides a list of the current members, output: 110 | 111 | ``` 112 | curl -X POST -d 'PEER' `docker-machine ip default`:3000/members 113 | ``` 114 | 115 | where PEER is an IP:PORT combination. 116 | 117 | ## Acknowledgements 118 | 119 | baseswim is sponsored by [nearForm](http://nearform.com). 120 | 121 | ## License 122 | 123 | MIT 124 | -------------------------------------------------------------------------------- /baseswim.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | const networkAddress = require('network-address') 6 | const Swim = require('swim') 7 | const assert = require('assert') 8 | const inherits = require('util').inherits 9 | const minimist = require('minimist') 10 | const pino = require('pino') 11 | const xtend = require('xtend') 12 | const control = require('./lib/control') 13 | const udpFreePort = require('udp-free-port') 14 | const defaults = { 15 | joinTimeout: 5000, 16 | pingTimeout: 200, // increase the swim default 10 times 17 | pingReqTimeout: 600, // increase the swim default 10 times 18 | interval: 200 // double swim default 19 | } 20 | 21 | function BaseSwim (id, opts) { 22 | if (!(this instanceof BaseSwim)) { 23 | return new BaseSwim(id, opts) 24 | } 25 | 26 | if (typeof id === 'object') { 27 | opts = id 28 | id = null 29 | } 30 | 31 | opts = xtend(defaults, opts) 32 | 33 | // cannot use xtend because it is not recursive 34 | opts.local = opts.local || {} 35 | opts.base = opts.base || [] 36 | 37 | // initialize the current host with the id 38 | opts.local.host = opts.local.host || id 39 | 40 | // hacky fix to have stable events 41 | let set = new Set() 42 | 43 | this.on(Swim.EventType.Change, (event) => { 44 | switch (event.state) { 45 | case 0: 46 | if (!set.has(event.host)) { 47 | set.add(event.host) 48 | this.emit('peerUp', event) 49 | } 50 | break 51 | } 52 | }) 53 | 54 | this.on(Swim.EventType.Update, (event) => { 55 | switch (event.state) { 56 | case 0: 57 | if (!set.has(event.host)) { 58 | set.add(event.host) 59 | this.emit('peerUp', event) 60 | } 61 | break 62 | case 1: 63 | this.emit('peerSuspect', event) 64 | break 65 | case 2: 66 | set.delete(event.host) 67 | this.emit('peerDown', event) 68 | break 69 | } 70 | }) 71 | 72 | const boot = () => { 73 | Swim.call(this, opts) 74 | 75 | this.bootstrap(opts.base, (err) => { 76 | if (err) { 77 | this.emit('error', err) 78 | return 79 | } 80 | if (opts.http) { 81 | if (typeof opts.http === 'number') { 82 | opts.http = { port: parseInt(opts.http) } 83 | } 84 | this._http = control(this) 85 | this._http.listen(opts.http.port || 3000, (err) => { 86 | if (err) { 87 | this.emit('error', err) 88 | return 89 | } 90 | this.emit('httpReady', opts.http.port) 91 | this.emit('up') 92 | }) 93 | } else { 94 | this.emit('up') 95 | } 96 | }) 97 | } 98 | 99 | const hostname = opts.host || networkAddress() 100 | 101 | if (!opts.local.host && opts.port) { 102 | opts.local.host = hostname + ':' + opts.port 103 | } else if (!opts.local.host && !opts.port) { 104 | udpFreePort((err, port) => { 105 | if (err) { 106 | this.emit('error', err) 107 | return 108 | } 109 | 110 | opts.local.host = hostname + ':' + port 111 | boot() 112 | }) 113 | 114 | return 115 | } 116 | 117 | assert(opts.local.host, 'missing id or opts.local.host or opts.port') 118 | 119 | boot() 120 | } 121 | 122 | inherits(BaseSwim, Swim) 123 | 124 | BaseSwim.EventType = Swim.EventType 125 | 126 | BaseSwim.prototype.leave = function () { 127 | if (this._http) { 128 | this._http.close() 129 | } 130 | Swim.prototype.leave.call(this) 131 | } 132 | 133 | module.exports = BaseSwim 134 | 135 | function start () { 136 | const logger = pino() 137 | const info = logger.info.bind(logger) 138 | const argv = minimist(process.argv.slice(2), { 139 | integer: ['port'], 140 | alias: { 141 | port: 'p', 142 | host: 'h', 143 | help: 'H', 144 | joinTimeout: 'j' 145 | }, 146 | default: { 147 | port: process.env.SWIM_PORT 148 | } 149 | }) 150 | 151 | if (argv.help) { 152 | console.error('Usage:', process.argv[1], '[--port PORT] [--host YOURIP] base1 base2') 153 | process.exit(1) 154 | } 155 | 156 | argv.base = argv._ 157 | 158 | let baseswim = new BaseSwim(argv) 159 | baseswim.on('httpReady', (port) => { 160 | info('http server listening on port %d', port) 161 | }) 162 | baseswim.on('peerUp', (peer) => { 163 | info(peer, 'peer online') 164 | }) 165 | baseswim.on('peerSuspect', (peer) => { 166 | info(peer, 'peer suspect') 167 | }) 168 | baseswim.on('peerDown', (peer) => { 169 | info(peer, 'peer offline') 170 | }) 171 | baseswim.on('up', (peer) => { 172 | info({ id: baseswim.whoami() }, 'I am up') 173 | }) 174 | } 175 | 176 | if (require.main === module) { 177 | start() 178 | } 179 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const baseswim = require('.') 5 | const Swim = require('swim') 6 | const request = require('request') 7 | 8 | let nextPort = 10001 9 | 10 | function nextId () { 11 | let result = '127.0.0.1:' + nextPort++ 12 | return result 13 | } 14 | 15 | function bootstrap (t, opts, cb) { 16 | if (typeof opts === 'function') { 17 | cb = opts 18 | opts = null 19 | } 20 | opts = opts || {} 21 | opts.joinTimeout = 20 22 | let instance = baseswim(nextId(), opts) 23 | t.tearDown(instance.leave.bind(instance)) 24 | 25 | instance.on('error', (err) => { 26 | console.log('instance error', instance.whoami(), err.message) 27 | }) 28 | 29 | instance.on('up', () => { 30 | let swim = new Swim({ 31 | local: { 32 | host: nextId() 33 | } 34 | }) 35 | swim.bootstrap([instance.whoami()], (err) => { 36 | t.error(err) 37 | t.tearDown(swim.leave.bind(swim)) 38 | t.deepEqual(instance.members(), [{ 39 | meta: undefined, 40 | host: swim.whoami(), 41 | state: 0, 42 | incarnation: 0 43 | }], 'parent members match') 44 | t.deepEqual(swim.members(), [{ 45 | meta: undefined, 46 | host: instance.whoami(), 47 | state: 0, 48 | incarnation: 0 49 | }], 'child members match') 50 | 51 | if (cb) { 52 | cb(instance, swim) 53 | } 54 | }) 55 | }) 56 | } 57 | 58 | test('comes up', (t) => { 59 | t.plan(3) 60 | bootstrap(t) 61 | }) 62 | 63 | test('comes up using automatic address lookup', (t) => { 64 | t.plan(3) 65 | let instance = baseswim({ port: nextPort++ }) 66 | t.tearDown(instance.leave.bind(instance)) 67 | 68 | instance.on('error', (err) => { 69 | console.log('instance error', instance.whoami(), err.message) 70 | }) 71 | 72 | instance.on('up', () => { 73 | let swim = new Swim({ 74 | local: { 75 | host: '127.0.0.1:' + nextPort++ 76 | } 77 | }) 78 | swim.bootstrap([instance.whoami()], (err) => { 79 | t.error(err) 80 | t.tearDown(swim.leave.bind(swim)) 81 | t.deepEqual(instance.members(), [{ 82 | meta: undefined, 83 | host: swim.whoami(), 84 | state: 0, 85 | incarnation: 0 86 | }], 'parent members match') 87 | t.deepEqual(swim.members(), [{ 88 | meta: undefined, 89 | host: instance.whoami(), 90 | state: 0, 91 | incarnation: 0 92 | }], 'child members match') 93 | }) 94 | }) 95 | }) 96 | 97 | test('comes up using automatic address lookup and port lookup', (t) => { 98 | t.plan(3) 99 | let instance = baseswim() 100 | t.tearDown(instance.leave.bind(instance)) 101 | 102 | instance.on('error', (err) => { 103 | console.log('instance error', instance.whoami(), err.message) 104 | }) 105 | 106 | instance.on('up', () => { 107 | let swim = new Swim({ 108 | local: { 109 | host: '127.0.0.1:' + nextPort++ 110 | } 111 | }) 112 | swim.bootstrap([instance.whoami()], (err) => { 113 | t.error(err) 114 | t.tearDown(swim.leave.bind(swim)) 115 | t.deepEqual(instance.members(), [{ 116 | meta: undefined, 117 | host: swim.whoami(), 118 | state: 0, 119 | incarnation: 0 120 | }], 'parent members match') 121 | t.deepEqual(swim.members(), [{ 122 | meta: undefined, 123 | host: instance.whoami(), 124 | state: 0, 125 | incarnation: 0 126 | }], 'child members match') 127 | }) 128 | }) 129 | }) 130 | 131 | test('exposes /members over http', (t) => { 132 | t.plan(5) 133 | bootstrap(t, { 134 | http: { 135 | port: 3000 136 | } 137 | }, function (instance, swim) { 138 | request('http://localhost:3000/members', (err, res, body) => { 139 | t.error(err) 140 | const expected = { 141 | members: [{ 142 | host: instance.whoami(), 143 | state: 0, 144 | incarnation: 0 145 | }, { 146 | host: swim.whoami(), 147 | state: 0, 148 | incarnation: 0 149 | }] 150 | } 151 | t.deepEqual(JSON.parse(body), expected, 'members matches') 152 | }) 153 | }) 154 | }) 155 | 156 | test('exposes /join over HTTP', (t) => { 157 | t.plan(6) 158 | bootstrap(t, { 159 | http: { 160 | port: 3000 161 | } 162 | }, function (instance, swim) { 163 | let secondId = nextId() 164 | let second = baseswim(secondId, { 165 | http: 3001, 166 | joinTimeout: 20 167 | }) 168 | t.tearDown(second.leave.bind(second)) 169 | second.on('up', () => { 170 | request.post({ 171 | url: 'http://localhost:3001/join', 172 | body: instance.whoami() 173 | }, (err, res, body) => { 174 | t.error(err) 175 | request('http://localhost:3000/members', (err, res, body) => { 176 | t.error(err) 177 | const expected = { 178 | members: [{ 179 | host: instance.whoami(), 180 | state: 0, 181 | incarnation: 0 182 | }, { 183 | host: swim.whoami(), 184 | state: 0, 185 | incarnation: 0 186 | }, { 187 | host: secondId, 188 | state: 0, 189 | incarnation: 0 190 | }] 191 | } 192 | t.deepEqual(JSON.parse(body), expected, 'members matches') 193 | }) 194 | }) 195 | }) 196 | }) 197 | }) 198 | 199 | test('peerUp/peerDown events from the cluster perspective', { timeout: 5000 }, (t) => { 200 | t.plan(6) 201 | bootstrap(t, { 202 | joinTimeout: 20 203 | }, (instance, swim) => { 204 | let secondId = nextId() 205 | let second = baseswim(secondId, { 206 | joinTimeout: 200, 207 | base: [instance.whoami()] 208 | }) 209 | t.tearDown(second.leave.bind(second)) 210 | second.on('up', () => { 211 | second.leave() 212 | }) 213 | instance.on('peerUp', (peer) => { 214 | t.equal(peer.host, second.whoami()) 215 | instance.on('peerSuspect', (peer) => { 216 | t.equal(peer.host, second.whoami()) 217 | }) 218 | instance.on('peerDown', (peer) => { 219 | t.equal(peer.host, second.whoami()) 220 | }) 221 | }) 222 | }) 223 | }) 224 | 225 | test('peerUp/peerDown events from the new node perspective', { timeout: 5000 }, (t) => { 226 | t.plan(6) 227 | bootstrap(t, { 228 | joinTimeout: 20 229 | }, (instance, swim) => { 230 | let secondId = nextId() 231 | let second = baseswim(secondId, { 232 | joinTimeout: 200, 233 | base: [instance.whoami()] 234 | }) 235 | t.tearDown(second.leave.bind(second)) 236 | second.once('peerUp', (peer) => { 237 | t.equal(peer.host, swim.whoami()) 238 | second.once('peerUp', (peer) => { 239 | t.equal(peer.host, instance.whoami()) 240 | swim.leave() 241 | second.on('peerSuspect', (peer) => { 242 | t.equal(peer.host, swim.whoami()) 243 | }) 244 | second.on('peerDown', (peer) => { 245 | t.equal(peer.host, swim.whoami()) 246 | }) 247 | }) 248 | }) 249 | }) 250 | }) 251 | --------------------------------------------------------------------------------