├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json └── tests └── registry.test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 2017, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "no-console": 0, 14 | "indent": [ 15 | "error", 16 | 4 17 | ], 18 | "linebreak-style": [ 19 | "error", 20 | "unix" 21 | ], 22 | "quotes": [ 23 | "error", 24 | "single" 25 | ], 26 | "semi": [ 27 | "error", 28 | "always" 29 | ], 30 | "object-curly-spacing": [ 31 | "error", 32 | "always" 33 | ], 34 | "array-bracket-spacing": [ 35 | "error", 36 | "always" 37 | ] 38 | } 39 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | 28 | node_modules 29 | 30 | .idea 31 | 32 | .env 33 | 34 | .vscode 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Umut Aydin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clerq 2 | 3 | a redis based minimalist service registry and service discovery 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm i --save clerq 9 | ``` 10 | 11 | You can also clone this repository and make use of it yourself. 12 | 13 | ```bash 14 | git clone https://github.com/Dvs-Bilisim/clerq.git 15 | cd clerq 16 | npm i 17 | npm test 18 | ``` 19 | 20 | Before running tests, please make sure that you have Redis available on localhost. 21 | If you don't know how to do that temporarily, please use following command to run it via docker. 22 | 23 | ```bash 24 | docker run -p 6379:6379 --name clerq_redis redis:4-alpine 25 | ``` 26 | 27 | ## Configuration 28 | 29 | - **cache :** expiration value in milliseconds for service caching. it's disabled by default. 30 | - **delimiter :** delimiter between prefix and service name. 31 | - **expire :** expire for service registry records. it's disabled by default. 32 | - **iface :** optional. name of the network interface to get outer ip from 33 | - **pino :** options for pino logger. it's { "level": "error" } by default. 34 | - **prefix :** prefix for service names 35 | 36 | ## Methods 37 | 38 | - **.up(service, [target]):** adds a new service instance 39 | - **.down(service, [target]):** removes an existing service instance 40 | - **.destroy(service):** removes an existing service completely 41 | - **.get(service):** returns a random instance by service 42 | - **.all(service):** returns all instances by service 43 | - **.services():** returns list of all services 44 | - **.isCached(service):** checks if service instance cached properly 45 | - **.stop():** stops service registry 46 | 47 | ## Examples 48 | 49 | ```js 50 | const ServiceRegistry = require('clerq'); 51 | const Redis = require('redis'); 52 | 53 | const registry = new ServiceRegistry(redis.createClient(), {}); 54 | 55 | registry.up('test', 8000).then(console.log).catch(console.log); 56 | registry.down('test', 8000).then(console.log).catch(console.log); 57 | registry.get('test').then(console.log).catch(console.log); 58 | registry.stop(); 59 | ``` 60 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ip = require('ip'); 4 | const is = require('is_js'); 5 | const pino = require('pino'); 6 | 7 | /** 8 | * @description Redis based service registry & service discovery 9 | * @class ServiceRegistry 10 | */ 11 | class ServiceRegistry { 12 | /** 13 | *Creates an instance of ServiceRegistry. 14 | * @param {Object} redis redis instance 15 | * @param {Object} options registry options 16 | * @memberof ServiceRegistry 17 | */ 18 | constructor(redis, options) { 19 | if (is.not.undefined(options) && is.not.object(options)) 20 | throw new Error('invalid options'); 21 | 22 | if (is.not.undefined(options) && is.not.object(options)) 23 | throw new Error('invalid options'); 24 | 25 | this._options = Object.assign({ prefix: 'clerq' }, options || {}); 26 | 27 | this._cache = {}; 28 | this._redis = redis; 29 | this._logger = pino(Object.assign({ level: 'error' }, is.object(this._options.pino) ? this._options.pino : {})); 30 | } 31 | 32 | /** 33 | * @description adds a new service instance 34 | * @param {String} service name 35 | * @param {String | Number} target address 36 | * @returns Promise 37 | * @memberof ServiceRegistry 38 | */ 39 | up(service, target) { 40 | return new Promise((resolve, reject) => { 41 | if (is.not.string(service) || is.empty(service)) throw new Error('INVALID_SERVICE'); 42 | 43 | const address = this._address(target), key = this._key(service); 44 | this._redis.sadd(key, address, (e, d) => { 45 | if (this._options.expire) this._redis.expire(key, this._options.expire); 46 | if (is.error(e)) { 47 | this._logger.error(e, 'clerq.up'); 48 | reject(e); 49 | } else { 50 | this._logger.info({ service, address, d }, 'clerq.up'); 51 | resolve(d); 52 | } 53 | }); 54 | }); 55 | } 56 | 57 | /** 58 | * @description removes an existing service instance 59 | * @param {String} service name 60 | * @param {String | Number} target address 61 | * @returns Promise 62 | * @memberof ServiceRegistry 63 | */ 64 | down(service, target) { 65 | return new Promise((resolve, reject) => { 66 | if (is.not.string(service) || is.empty(service)) throw new Error('INVALID_SERVICE'); 67 | 68 | const address = this._address(target), key = this._key(service); 69 | this._redis.srem(key, address, (e, d) => { 70 | if (this._options.expire) this._redis.expire(key, this._options.expire); 71 | if (is.error(e)) { 72 | this._logger.error(e, 'clerq.down'); 73 | reject(e); 74 | } else { 75 | this._logger.info({ service, address, d }, 'clerq.down'); 76 | resolve(d); 77 | } 78 | }); 79 | }); 80 | } 81 | 82 | /** 83 | * @description removes an existing service completely 84 | * @param {String} service name 85 | * @returns Promise 86 | * @memberof ServiceRegistry 87 | */ 88 | destroy(service) { 89 | return new Promise((resolve, reject) => { 90 | if (is.not.string(service) || is.empty(service)) throw new Error('INVALID_SERVICE'); 91 | 92 | const key = this._key(service); 93 | this._redis.expire(key, 1, e => { 94 | if (is.error(e)) { 95 | this._logger.error(e, 'clerq.destroy'); 96 | reject(e); 97 | } else { 98 | this._logger.info({ service }, 'clerq.destroy'); 99 | resolve(true); 100 | } 101 | }); 102 | }); 103 | } 104 | 105 | /** 106 | * @description returns a random instance by service 107 | * @param {String} service name 108 | * @returns Promise 109 | * @memberof ServiceRegistry 110 | */ 111 | get(service) { 112 | return new Promise((resolve, reject) => { 113 | if (is.not.string(service) || is.empty(service)) throw new Error('INVALID_SERVICE'); 114 | else if (this.isCached(service)) return resolve(this._cache[service].d); 115 | 116 | const key = this._key(service); 117 | this._redis.srandmember(key, (e, d) => { 118 | if (this._options.expire) this._redis.expire(key, this._options.expire); 119 | if (is.error(e)) { 120 | this._logger.error(e, 'clerq.get'); 121 | reject(e); 122 | } else { 123 | if (is.number(this._options.cache) && this._options.cache > 0) 124 | if (d) this._cache[service] = { d, t: Date.now() }; 125 | this._logger.info({ service, d }, 'clerq.get'); 126 | resolve(d); 127 | } 128 | }); 129 | }); 130 | } 131 | 132 | /** 133 | * @description returns all instances by service 134 | * @param {String} service name 135 | * @returns Promise 136 | * @memberof ServiceRegistry 137 | */ 138 | all(service) { 139 | return new Promise((resolve, reject) => { 140 | if (is.not.string(service) || is.empty(service)) throw new Error('INVALID_SERVICE'); 141 | 142 | const key = this._key(service); 143 | this._redis.smembers(key, (e, d) => { 144 | if (this._options.expire) this._redis.expire(key, this._options.expire); 145 | if (is.error(e)) { 146 | this._logger.error(e, 'clerq.all'); 147 | reject(e); 148 | } else { 149 | if (is.number(this._options.cache) && this._options.cache > 0) 150 | if (is.array(d) && is.not.empty(d)) { 151 | const address = d[ Math.floor(Math.random() * d.length) ]; 152 | this._cache[service] = { d: address, t: Date.now() }; 153 | } 154 | this._logger.info({ service, d }, 'clerq.all'); 155 | resolve(d); 156 | } 157 | }); 158 | }); 159 | } 160 | 161 | /** 162 | * @description returns list of all services 163 | * @returns Promise 164 | * @memberof ServiceRegistry 165 | */ 166 | services() { 167 | return new Promise((resolve, reject) => { 168 | this._redis.keys(`${ this._options.prefix }*`, (e, d) => { 169 | if (is.error(e)) { 170 | this._logger.error(e, 'clerq.services'); 171 | reject(e); 172 | } else { 173 | const services = []; 174 | if (is.array(d)) 175 | for (let service of d) 176 | services.push(service.replace(this._key(), '')); 177 | this._logger.info(services, 'clerq.services'); 178 | resolve(services); 179 | } 180 | }); 181 | }); 182 | } 183 | 184 | /** 185 | * @description checks if service instance cached properly 186 | * @param {String} service 187 | * @returns {Boolean} 188 | * @memberof ServiceRegistry 189 | */ 190 | isCached(service) { 191 | if (is.number(this._options.cache) && this._options.cache > 0 && is.existy(this._cache[service])) { 192 | const cached = this._cache[service]; 193 | if (is.object(cached)) return Math.abs(Date.now() - cached.t) < this._options.cache; 194 | } 195 | return false; 196 | } 197 | 198 | /** 199 | * @description stops service registry 200 | * @memberof ServiceRegistry 201 | */ 202 | stop() { 203 | this._redis.quit(); 204 | this._logger.info('service registry is down'); 205 | } 206 | 207 | /** 208 | * @description builds address up 209 | * @private 210 | * @returns String | undefined 211 | * @memberof ServiceRegistry 212 | */ 213 | _address(target) { 214 | if (is.number(target)) return `${ ip.address(this._options.iface) }:${ Math.abs(target) }`; 215 | else if (is.string(target)) { 216 | if (!target.includes(':')) { 217 | const port = parseInt(target); 218 | if (is.number(port)) return `${ ip.address(this._options.iface) }:${ Math.abs(port) }`; 219 | } else return target; 220 | } 221 | } 222 | 223 | /** 224 | * @description builds key up 225 | * @private 226 | * @returns String 227 | * @memberof ServiceRegistry 228 | */ 229 | _key(service) { 230 | const key = `${ this._options.prefix }${ this._options.delimiter || '::' }`; 231 | if (!service) return key; 232 | else return `${ key }${ service }`; 233 | } 234 | } 235 | 236 | module.exports = ServiceRegistry; 237 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clerq", 3 | "version": "0.5.0", 4 | "description": "a redis based minimalist service registry and service discovery", 5 | "keywords": [ 6 | "redis", 7 | "service discovery", 8 | "service registry" 9 | ], 10 | "main": "index.js", 11 | "scripts": { 12 | "coverage": "./node_modules/.bin/jest --coverage", 13 | "test": "./node_modules/.bin/jest" 14 | }, 15 | "author": { 16 | "name": "Umut Aydin" 17 | }, 18 | "engines": { 19 | "node": ">=v6.14.4" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/Dvs-Bilisim/clerq/issues" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git://github.com/Dvs-Bilisim/clerq.git" 27 | }, 28 | "license": "MIT", 29 | "devDependencies": { 30 | "eslint": "^7.0.0", 31 | "eslint-config-airbnb-base": "^14.1.0", 32 | "eslint-plugin-import": "^2.20.2", 33 | "jest": "^26.0.1", 34 | "redis-mock": "^0.49.0" 35 | }, 36 | "dependencies": { 37 | "ip": "^1.1.5", 38 | "is_js": "^0.9.0", 39 | "pino": "^6.2.1" 40 | }, 41 | "jest": { 42 | "testEnvironment": "node" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/registry.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const redis = require('redis-mock'); 4 | const ServiceRegistry = require('../'); 5 | 6 | const name = Math.random().toString().replace('0.', ''); 7 | const port = Math.floor(Math.random() * 9999); 8 | const port2 = Math.floor(Math.random() * 9999); 9 | const registry = new ServiceRegistry(redis.createClient(), { cache: 60000, expire: 5, pino: { level: 'fatal' } }); 10 | 11 | afterAll(() => registry.stop()); 12 | 13 | test('address', async () => { 14 | const target = `${ Math.random().toString().replace('0.', '') }:${ Math.random().toString().replace('0.', '') }`; 15 | expect(registry._address(target)).toBe(target); 16 | }); 17 | 18 | test('new service', async () => { 19 | const up = await registry.up(name, port); 20 | expect(up).toBe(1); 21 | }); 22 | 23 | test('yet another service', async () => { 24 | const up = await registry.up(name, port2); 25 | expect(up).toBe(1); 26 | }); 27 | 28 | test('check service', async () => { 29 | const service = await registry.get(name); 30 | expect(service).toBeTruthy(); 31 | }); 32 | 33 | test('is cached', async () => { 34 | expect(registry.isCached(name)).toBeTruthy(); 35 | }); 36 | 37 | test('list service', async () => { 38 | const services = await registry.all(name); 39 | expect(services.length).toBe(2); 40 | }); 41 | 42 | test('via cache', async () => { 43 | const service = await registry.get(name); 44 | expect(service).toBeTruthy(); 45 | }); 46 | 47 | test('all services', async () => { 48 | const services = await registry.services(); 49 | expect(services.includes(name)).toBeTruthy(); 50 | }); 51 | 52 | test('destroy services', async () => { 53 | const service = await registry.destroy(name); 54 | expect(service).toBeTruthy(); 55 | }); 56 | --------------------------------------------------------------------------------