├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── client.js └── server.js ├── examples └── simple.js ├── index.js ├── lib ├── client │ └── Client.js ├── defaultLogger.js └── server │ ├── RedisData.js │ ├── Server.js │ ├── httpApi.js │ └── stats.js ├── package.json └── test ├── test.data.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "laxcomma" : true, 3 | "node" : true, 4 | "esnext" : true, 5 | "multistr" : true 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Thalassa - Copyright (c) 2013 Pearson. All rights reserved. 2 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 3 | use this file except in compliance with the License. 4 | 5 | Please see the package.json file for third party software dependencies and 6 | related licenses. 7 | 8 | Apache License 9 | Version 2.0, January 2004 10 | http://www.apache.org/licenses/ 11 | 12 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 13 | 14 | 1. Definitions. 15 | 16 | "License" shall mean the terms and conditions for use, reproduction, 17 | and distribution as defined by Sections 1 through 9 of this document. 18 | 19 | "Licensor" shall mean the copyright owner or entity authorized by 20 | the copyright owner that is granting the License. 21 | 22 | "Legal Entity" shall mean the union of the acting entity and all 23 | other entities that control, are controlled by, or are under common 24 | control with that entity. For the purposes of this definition, 25 | "control" means (i) the power, direct or indirect, to cause the 26 | direction or management of such entity, whether by contract or 27 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 28 | outstanding shares, or (iii) beneficial ownership of such entity. 29 | 30 | "You" (or "Your") shall mean an individual or Legal Entity 31 | exercising permissions granted by this License. 32 | 33 | "Source" form shall mean the preferred form for making modifications, 34 | including but not limited to software source code, documentation 35 | source, and configuration files. 36 | 37 | "Object" form shall mean any form resulting from mechanical 38 | transformation or translation of a Source form, including but 39 | not limited to compiled object code, generated documentation, 40 | and conversions to other media types. 41 | 42 | "Work" shall mean the work of authorship, whether in Source or 43 | Object form, made available under the License, as indicated by a 44 | copyright notice that is included in or attached to the work 45 | (an example is provided in the Appendix below). 46 | 47 | "Derivative Works" shall mean any work, whether in Source or Object 48 | form, that is based on (or derived from) the Work and for which the 49 | editorial revisions, annotations, elaborations, or other modifications 50 | represent, as a whole, an original work of authorship. For the purposes 51 | of this License, Derivative Works shall not include works that remain 52 | separable from, or merely link (or bind by name) to the interfaces of, 53 | the Work and Derivative Works thereof. 54 | 55 | "Contribution" shall mean any work of authorship, including 56 | the original version of the Work and any modifications or additions 57 | to that Work or Derivative Works thereof, that is intentionally 58 | submitted to Licensor for inclusion in the Work by the copyright owner 59 | or by an individual or Legal Entity authorized to submit on behalf of 60 | the copyright owner. For the purposes of this definition, "submitted" 61 | means any form of electronic, verbal, or written communication sent 62 | to the Licensor or its representatives, including but not limited to 63 | communication on electronic mailing lists, source code control systems, 64 | and issue tracking systems that are managed by, or on behalf of, the 65 | Licensor for the purpose of discussing and improving the Work, but 66 | excluding communication that is conspicuously marked or otherwise 67 | designated in writing by the copyright owner as "Not a Contribution." 68 | 69 | "Contributor" shall mean Licensor and any individual or Legal Entity 70 | on behalf of whom a Contribution has been received by Licensor and 71 | subsequently incorporated within the Work. 72 | 73 | 2. Grant of Copyright License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | copyright license to reproduce, prepare Derivative Works of, 77 | publicly display, publicly perform, sublicense, and distribute the 78 | Work and such Derivative Works in Source or Object form. 79 | 80 | 3. Grant of Patent License. Subject to the terms and conditions of 81 | this License, each Contributor hereby grants to You a perpetual, 82 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 83 | (except as stated in this section) patent license to make, have made, 84 | use, offer to sell, sell, import, and otherwise transfer the Work, 85 | where such license applies only to those patent claims licensable 86 | by such Contributor that are necessarily infringed by their 87 | Contribution(s) alone or by combination of their Contribution(s) 88 | with the Work to which such Contribution(s) was submitted. If You 89 | institute patent litigation against any entity (including a 90 | cross-claim or counterclaim in a lawsuit) alleging that the Work 91 | or a Contribution incorporated within the Work constitutes direct 92 | or contributory patent infringement, then any patent licenses 93 | granted to You under this License for that Work shall terminate 94 | as of the date such litigation is filed. 95 | 96 | 4. Redistribution. You may reproduce and distribute copies of the 97 | Work or Derivative Works thereof in any medium, with or without 98 | modifications, and in Source or Object form, provided that You 99 | meet the following conditions: 100 | 101 | (a) You must give any other recipients of the Work or 102 | Derivative Works a copy of this License; and 103 | 104 | (b) You must cause any modified files to carry prominent notices 105 | stating that You changed the files; and 106 | 107 | (c) You must retain, in the Source form of any Derivative Works 108 | that You distribute, all copyright, patent, trademark, and 109 | attribution notices from the Source form of the Work, 110 | excluding those notices that do not pertain to any part of 111 | the Derivative Works; and 112 | 113 | (d) If the Work includes a "NOTICE" text file as part of its 114 | distribution, then any Derivative Works that You distribute must 115 | include a readable copy of the attribution notices contained 116 | within such NOTICE file, excluding those notices that do not 117 | pertain to any part of the Derivative Works, in at least one 118 | of the following places: within a NOTICE text file distributed 119 | as part of the Derivative Works; within the Source form or 120 | documentation, if provided along with the Derivative Works; or, 121 | within a display generated by the Derivative Works, if and 122 | wherever such third-party notices normally appear. The contents 123 | of the NOTICE file are for informational purposes only and 124 | do not modify the License. You may add Your own attribution 125 | notices within Derivative Works that You distribute, alongside 126 | or as an addendum to the NOTICE text from the Work, provided 127 | that such additional attribution notices cannot be construed 128 | as modifying the License. 129 | 130 | You may add Your own copyright statement to Your modifications and 131 | may provide additional or different license terms and conditions 132 | for use, reproduction, or distribution of Your modifications, or 133 | for any such Derivative Works as a whole, provided Your use, 134 | reproduction, and distribution of the Work otherwise complies with 135 | the conditions stated in this License. 136 | 137 | 5. Submission of Contributions. Unless You explicitly state otherwise, 138 | any Contribution intentionally submitted for inclusion in the Work 139 | by You to the Licensor shall be under the terms and conditions of 140 | this License, without any additional terms or conditions. 141 | Notwithstanding the above, nothing herein shall supersede or modify 142 | the terms of any separate license agreement you may have executed 143 | with Licensor regarding such Contributions. 144 | 145 | 6. Trademarks. This License does not grant permission to use the trade 146 | names, trademarks, service marks, or product names of the Licensor, 147 | except as required for reasonable and customary use in describing the 148 | origin of the Work and reproducing the content of the NOTICE file. 149 | 150 | 7. Disclaimer of Warranty. Unless required by applicable law or 151 | agreed to in writing, Licensor provides the Work (and each 152 | Contributor provides its Contributions) on an "AS IS" BASIS, 153 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 154 | implied, including, without limitation, any warranties or conditions 155 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 156 | PARTICULAR PURPOSE. You are solely responsible for determining the 157 | appropriateness of using or redistributing the Work and assume any 158 | risks associated with Your exercise of permissions under this License. 159 | 160 | 8. Limitation of Liability. In no event and under no legal theory, 161 | whether in tort (including negligence), contract, or otherwise, 162 | unless required by applicable law (such as deliberate and grossly 163 | negligent acts) or agreed to in writing, shall any Contributor be 164 | liable to You for damages, including any direct, indirect, special, 165 | incidental, or consequential damages of any character arising as a 166 | result of this License or out of the use or inability to use the 167 | Work (including but not limited to damages for loss of goodwill, 168 | work stoppage, computer failure or malfunction, or any and all 169 | other commercial damages or losses), even if such Contributor 170 | has been advised of the possibility of such damages. 171 | 172 | 9. Accepting Warranty or Additional Liability. While redistributing 173 | the Work or Derivative Works thereof, You may choose to offer, 174 | and charge a fee for, acceptance of support, warranty, indemnity, 175 | or other liability obligations and/or rights consistent with this 176 | License. However, in accepting such obligations, You may act only 177 | on Your own behalf and on Your sole responsibility, not on behalf 178 | of any other Contributor, and only if You agree to indemnify, 179 | defend, and hold each Contributor harmless for any liability 180 | incurred by, or claims asserted against, such Contributor by reason 181 | of your accepting any such warranty or additional liability. 182 | 183 | END OF TERMS AND CONDITIONS 184 | 185 | APPENDIX: How to apply the Apache License to your work. 186 | 187 | To apply the Apache License to your work, attach the following 188 | boilerplate notice, with the fields enclosed by brackets "[]" 189 | replaced with your own identifying information. (Don't include 190 | the brackets!) The text should be enclosed in the appropriate 191 | comment syntax for the file format. We also recommend that a 192 | file or class name and description of purpose be included on the 193 | same "printed page" as the copyright notice for easier 194 | identification within third-party archives. 195 | 196 | Licensed under the Apache License, Version 2.0 (the "License"); 197 | you may not use this file except in compliance with the License. 198 | You may obtain a copy of the License at 199 | 200 | http://www.apache.org/licenses/LICENSE-2.0 201 | 202 | Unless required by applicable law or agreed to in writing, software 203 | distributed under the License is distributed on an "AS IS" BASIS, 204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 205 | See the License for the specific language governing permissions and 206 | limitations under the License. 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Thalassa 2 | ======== 3 | 4 | Thalassa is a lightweight service registry build primarily on [node.js](http://nodejs.org/), [Redis](http://redis.io/) and [Axon](https://github.com/visionmedia/axon), inspired by [Seaport](https://github.com/substack/seaport). Thalassa is actually a system of components primarily geared to enable continuous deployment scenarios through dynamic configuration of [HAProxy](http://haproxy.1wt.eu/) load balancers and seamless, no connection drop A/B deploys. 5 | 6 | This is the central module that includes the server registry and client. The registry uses a ping and expiration type approach. Clients register with the server and pass an optional `secondsToExpire` property telling the server when it is acceptable to expire the registration if the registration is not updated in that time. There is an internal "reaper" function that runs periodically to reap expired registrations. More on that later. 7 | 8 | [![NPM](https://nodei.co/npm/thalassa.png)](https://nodei.co/npm/thalassa/) 9 | 10 | ### History 11 | 12 | Thalassa is a second generation system, superseding what was otherwise knows as "Spindrift" inside of [Pearson](http://www.pearson.com/). Spindrift leaned heavily on [@substack](https://github.com/substack)'s Seaport module. Incidentally, Seaport was the original inspiration for the aquatic theme of Spindrift and Thalassa. 13 | 14 | In Greek mythology, Thalassa was the primeval spirit of the sea. In the fables of Aesop, Thalassa appears as a woman formed of sea water rising up from her native element. Thalassa was depicted in Roman-era mosaics as a woman half submerged in the sea, with crab-claw horns, clothed in bands of seaweed, and holding a ship's oar.[1](http://www.theoi.com/Protogenos/Thalassa.html) 15 | 16 | 17 | # Installation 18 | 19 | npm install thalassa 20 | 21 | or globally 22 | 23 | npm install -g thalassa 24 | 25 | # Running the Server 26 | 27 | The Thalassa server and client may be run from the command line or embedded as a module within your application. 28 | 29 | ## Running from Command Line 30 | 31 | Assuming Redis is installed (version >= 2.6 required due to use of EVALHSA command) and running, start the thalassa server with default options (NOTE that the default REST API port is 9000): 32 | 33 | ./node_modules/.bin/thalassa-server --debug 34 | 35 | or 36 | 37 | thalassa-server --debug 38 | 39 | 40 | ### Server Command Line Options 41 | 42 | thalassa-server --help 43 | Options: 44 | --host host to bind to [default: "127.0.0.1"] 45 | --port port to bind to for axon socket [default: 5001] 46 | --apihost host to bind to [default: "127.0.0.1"] 47 | --apiport port to bind to for http api [default: 9000] 48 | --redisHost Redis host [default: "127.0.0.1"] 49 | --redisPort Redis port [default: 6379] 50 | --redisDatabase Redis database to select [default: 0] 51 | --reaperFreq Reaper frequency (ms) [default: 2000] 52 | --debug enabled debug logging 53 | 54 | 55 | ## Server as an Embedded Module 56 | 57 | The same options above (except `debug`) may be passed by properties set in the `opts` object. For example using `new Thalassa.Server(opts)`: 58 | 59 | var Thalassa = require('thalassa'); 60 | 61 | var server = new Thalassa.Server({ 62 | port: 4444, 63 | apiport: 4445, 64 | host: 'localhost' 65 | }); 66 | 67 | In addition `opts.log` may be optionally set to your own function to handle logging. `opts.log` expects this signature: `function log (level, message, object){}`. `level` will be one of `debug`, `info`, and `error`. `message` is a string and `object` is an optional object with key value pairs. Of `opts.log` is not passed, the module will be quiet. 68 | 69 | 70 | # Running the Client 71 | 72 | The client can be run any of three ways. 73 | 74 | 1. From the command-line 75 | 2. As a module 76 | 3. Over HTTP 77 | 78 | ## Running Client from Command Line 79 | 80 | Why would you do this? Let's say you have an existing legacy Java application that you'd rather not change. You can create a sister service that invokes the command line client to register the service on it's behalf. 81 | 82 | For example, if Thalassa is installed globally (other wise `./node_modules/.bin/thalassa-client): 83 | 84 | thalassa-client --register myapp@1.0.0:8080 --debug 85 | 86 | This registers the application named `my app` at version `1.0.0` that's on the current host on port `8080`. The client will continue to ping the Thalassa server with updates. 87 | 88 | ### Client Command Line Options 89 | 90 | thalassa-client --help 91 | Options: 92 | --host thalassa host [default: "127.0.0.1"] 93 | --port thalassa axon socket port [default: 5001] 94 | --apiport thalassa http api port [default: 9000] 95 | --register name@x.x.x:port,name@x.x.x:port [required] 96 | --secsToExpire default time in seconds for a thalassa registration to be valid [default: 60] 97 | --updateFreq time frequency in ms to ping the thalassa server [default: 20000] 98 | --updateTimeout time in ms to wait for a registrion request to respond [default: 2500] 99 | --debug enabled debug logging 100 | 101 | ## Client as an Embedded Module 102 | 103 | Using the client from within a node.js application to register your service is simple. Pass options via the `opts` object like `new Thalassa.Client(opts)`: 104 | 105 | var Thalassa = require('thalassa'); 106 | 107 | var client = new Thalassa.Client({ 108 | port: 4444, 109 | apiport: 4445, 110 | host: 'localhost' 111 | }); 112 | 113 | client.register('myapp', '1.0.0', 8080); 114 | 115 | // start reporting registrations to the server 116 | client.start(); 117 | 118 | // stop reporting registrations to the server 119 | client.stop(); 120 | 121 | `opts.log` may be passed just like the server. 122 | 123 | ### `updateSuccessful` and `updateFailed` Events 124 | 125 | The client will periodically check in with the Thalassa server according to `opts.updateFreq` (default 20000ms). Each registration will product a `updateSuccessful` or `updateFailed` event to be emitted. 126 | 127 | client.on('updateSuccessful', function () {}); 128 | client.on('updateFailed', function (error) {}); 129 | 130 | ### Subscriptions and `online` and `offline` Events 131 | 132 | If running as a module, you also have access to `subscribe` to `online` and `offline` events of certain applications. For example: 133 | 134 | client.subscribe('myapp', '1.0.0'); 135 | client.on('online', function (registration) {}); 136 | client.on('offline', function (registration) {}); 137 | 138 | Alternatively for all versions of `myapp`: 139 | 140 | client.subscribe('myapp'); 141 | 142 | Or every service registration: 143 | 144 | client.subscribe(); 145 | 146 | ### Querying Registrations 147 | 148 | Also as a module, you can use the client API to query for registrations. 149 | 150 | client.getRegistrations('myapp', '1.0.0', function (err, registrations) { 151 | // registrations is an Array of Registrations 152 | } 153 | See the HTTP API section for the `Registration` structure. 154 | 155 | ### Metadata 156 | 157 | You can also pass metadata with any registration as a fourth parameter. This can be any javascript object with properties. For example: 158 | 159 | var meta = { 160 | az: 'use1a', 161 | size: 'm1.large', 162 | foo: { 163 | bar: 'baz' 164 | } 165 | }; 166 | client.register('myapp', '1.0.0', 8080, meta) 167 | 168 | ## HTTP Client 169 | 170 | The Thalassa server exposes a simple HTTP API so it's not necessary to use the `node.js` client and any application that's capable of calling HTTP can participate as an application in the system. See the HTTP API. 171 | 172 | # HTTP API 173 | 174 | ### GET `/registrations/{name}/{version}` 175 | ### GET `/registrations/{name}` 176 | ### GET `/registrations` 177 | 178 | Return `Registrations[]` of all registrations for the optionally provided `name` and `version`. `/registrations` returns everything. 179 | 180 | 181 | `Registration` is defined in it's own module `thalassa-registrations`. A typical registration looks like this: 182 | 183 | { 184 | "name": "myapp", 185 | "version": "1.0.0", 186 | "host": "192.168.8.106", 187 | "port": 8080, 188 | "lastKnown": 1378682020883, 189 | "meta": { 190 | "hostname": "mb-mbp.local", 191 | "pid": 66593, 192 | "registered": 1378682010864 193 | }, 194 | "id": "/myapp/1.0.0/192.168.8.106/8080" 195 | } 196 | 197 | All times are in Unix time since epoch form. 198 | 199 | The Thalassa client will automatically add `meta.pid` and the server will automatically add `registered` and `hostname` if not provided. If a `hostname` is not provided by the client, the IP will be used instead. Additionally, the Thalassa client will automatically set `hostname` to `require('os').hostname()`. 200 | 201 | ### POST `/registrations/{name}/{version}/{host}/{port}` 202 | 203 | Create or update a registration. 204 | 205 | The BODY of the POST should be `application/json` and will be added to `meta`. 206 | 207 | Additionally `meta.secondsToExpire` should be set to explicitly set the expiration time of the registration. In essence you are telling the Thalassa server, if you don't hear back from me in so many seconds, expire my registration and fire an `offline` event. This properly allows the client to tune how often they poll balanced with how long they are willing to accept stale registration data. If you set `secondsToExpire` to `300` then you may poll every ten minutes, but if your service goes down or is underplayed, consumers won't know it for at most `300` seconds. 208 | 209 | ### DELETE `/registrations/{name}/{version}/{host}/{port}` 210 | 211 | Explicitly delete a registration, causing an `offline` event. 212 | 213 | ### STATUS `status` 214 | 215 | Returns basic status with thalassa version, memory usage, uptime and registrationsPerSecond stats. 216 | 217 | # Known Limitations and Roadmap 218 | 219 | Thalassa currently doesn't implement any type of authentication or authorization and at this point expects to be running on a trusted private network. This will be addressed in the future. Ultimately auth should be extensible and customizable. Suggestions and pull requests welcome! 220 | 221 | # License 222 | 223 | Licensed under Apache 2.0. See [LICENSE](https://github.com/PearsonEducation/thalassa/blob/master/LICENSE) file. 224 | -------------------------------------------------------------------------------- /bin/client.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var util = require('util') 4 | , Thalassa = require('..') 5 | , optimist = require('optimist') 6 | .options({ 7 | host: { 8 | default : '127.0.0.1', 9 | describe: 'thalassa host' 10 | }, 11 | port: { 12 | default : 5001, 13 | describe: 'thalassa axon socket port' 14 | }, 15 | apiport: { 16 | default : 9000, 17 | describe: 'thalassa http api port' 18 | }, 19 | register: { 20 | describe: 'name@x.x.x:port,name@x.x.x:port' 21 | }, 22 | secsToExpire: { 23 | default : 60, 24 | describe: 'default time in seconds for a thalassa registration to be valid' 25 | }, 26 | updateFreq: { 27 | default : 20000, 28 | describe: 'time frequency in ms to ping the thalassa server' 29 | }, 30 | updateTimeout: { 31 | default : 2500, 32 | describe: 'time in ms to wait for a registration request to respond' 33 | }, 34 | debug: { 35 | boolean: true, 36 | describe: 'enabled debug logging' 37 | }, 38 | showhelp: { 39 | alias: 'h' 40 | } 41 | }) 42 | .demand('register'); 43 | 44 | var argv = optimist.argv; 45 | 46 | if (argv.h) { 47 | optimist.showHelp(); 48 | process.exit(0); 49 | } 50 | 51 | var log = argv.log = require('../lib/defaultLogger')( (argv.debug == true) ? 'debug' : 'error' ); 52 | 53 | var client = new Thalassa.Client(argv); 54 | 55 | // TODO validate format of `register` option 56 | argv.register.split(',').forEach(function (nvp) { 57 | var parts = nvp.split('@'); 58 | var name = parts[0]; 59 | parts = parts[1].split(':'); 60 | var version = parts[0]; 61 | var port = parts[1]; 62 | client.register(name, version, port); 63 | // client.subscribe('thalassa-aqueduct') 64 | // client.on('online', console.log.bind(console)); 65 | // client.on('offline', console.log.bind(console)); 66 | log('info', util.format('registering %s@%s on port %s', name, version, port)); 67 | }) 68 | 69 | client.start(); 70 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var optimist = require('optimist') 3 | .options({ 4 | host: { 5 | default : '127.0.0.1', 6 | describe: 'host to bind to' 7 | }, 8 | port: { 9 | default : 5001, 10 | describe: 'port to bind to for axon socket' 11 | }, 12 | apihost: { 13 | default : '127.0.0.1', 14 | describe: 'host to bind to' 15 | }, 16 | apiport: { 17 | default : 9000, 18 | describe: 'port to bind to for http api' 19 | }, 20 | redisHost: { 21 | default : '127.0.0.1', 22 | describe: 'Redis host' 23 | }, 24 | redisPort: { 25 | default : 6379, 26 | describe: 'Redis port' 27 | }, 28 | redisDatabase: { 29 | default : 0, 30 | describe: 'Redis database to select' 31 | }, 32 | reaperFreq: { 33 | default : 2000, 34 | describe: 'Reaper frequency (ms)' 35 | }, 36 | debug: { 37 | boolean: true, 38 | describe: 'enabled debug logging' 39 | }, 40 | help: { 41 | alias: 'h' 42 | } 43 | }); 44 | 45 | var argv = optimist.argv; 46 | 47 | if (argv.h) { 48 | optimist.showHelp(); 49 | process.exit(0); 50 | } 51 | 52 | argv.log = require('../lib/defaultLogger')( (argv.debug == true) ? 'debug' : 'error' ); 53 | var Thalassa = require('..'); 54 | var server = new Thalassa.Server(argv); 55 | -------------------------------------------------------------------------------- /examples/simple.js: -------------------------------------------------------------------------------- 1 | var Thalassa = require('..') 2 | , request = require('request') 3 | ; 4 | 5 | var log = require('../lib/defaultLogger')('debug'); 6 | 7 | // 8 | // Create a server 9 | // 10 | var server = new Thalassa.Server({ 11 | port: 4444, 12 | apiport: 4445, 13 | host: 'localhost', 14 | log: log 15 | }); 16 | 17 | // 18 | // Create a client 19 | // 20 | var client = new Thalassa.Client({ 21 | port: 4444, 22 | apiport: 4445, 23 | host: 'localhost', 24 | log: log 25 | }); 26 | 27 | // 28 | // Register a callback to handle the online notifications of the 29 | // services we'll subscribe to 30 | // 31 | client.once('online', function (registration) { 32 | console.log('----- via socket ---------------------'); 33 | console.log(registration); 34 | console.log('--------------------------------------'); 35 | }); 36 | 37 | // 38 | // Subscribe to the app and version to be notified of online and offline events. 39 | // If you don't pass any arguments you'll be notified for all services, if only 40 | // the app name then you'll be notified regardless of version. 41 | // 42 | client.subscribe('myapp', '1.0.0'); 43 | 44 | // 45 | // Register our service and start the client so that it will beginning notifying 46 | // the Thalassa server periodically, reupdating our subscription. 47 | // 48 | client.register('myapp', '1.0.0', 8080); 49 | client.start(); 50 | 51 | 52 | // 53 | // Wait a second and then query to make sure our registration exists, via the HTTP 54 | // API and through the client (which also uses the HTTP api) 55 | // 56 | setTimeout(function() { 57 | // 58 | // look up via http 59 | // 60 | request({ 61 | uri: 'http://localhost:4445/registrations/myapp/1.0.0', 62 | json: true 63 | }, function (error, resp, body) { 64 | console.log('----- via http -----------------------'); 65 | console.log(body); 66 | console.log('--------------------------------------'); 67 | }); 68 | 69 | // 70 | // look up via client (via http) 71 | // 72 | client.getRegistrations('myapp', '1.0.0', function (err, registrations) { 73 | console.log('----- via client ---------------------'); 74 | console.log(registrations); 75 | console.log('--------------------------------------'); 76 | 77 | // 78 | // For the sake of this example, close out the client and server so the process 79 | // will shutdown cleanly 80 | // 81 | server.close(); 82 | client.close(); 83 | }); 84 | 85 | }, 1000); 86 | 87 | 88 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | Server: require('./lib/server/Server'), 4 | Client: require('./lib/client/Client') 5 | }; 6 | -------------------------------------------------------------------------------- /lib/client/Client.js: -------------------------------------------------------------------------------- 1 | var axon = require('axon') 2 | , os = require('os') 3 | , util = require('util') 4 | , ip = require('ip') 5 | , request = require('request') 6 | , registrations = require('thalassa-registrations') 7 | , EventEmitter = require('events').EventEmitter 8 | , deepEqual = require('deep-equal') 9 | ; 10 | 11 | 12 | /** 13 | * `Client` constructor 14 | * @constructor 15 | * 16 | * @param {number} [opts.port=5001] - Port number of axon socket port 17 | * @param {number} [opts.apiport=9000] - Port number of Thalassa HTTP API 18 | * @param {String} [opts.host=127.0.0.1] - Thalassa host 19 | * @param {number} [opts.updateFreq=20000] - How often to check into registrations to Thalassa server 20 | * @param {number} [opts.updateTimeout=2500] - How long to wait for a registrion request to respond 21 | * @param {String} [opts.mode=http] 22 | */ 23 | 24 | var Client = module.exports = function (opts) { 25 | if (typeof opts !== 'object') opts = {}; 26 | this.log = (typeof opts.log === 'function') ? opts.log : function (){}; 27 | 28 | EventEmitter.call(this); 29 | 30 | this.APIPORT = opts.apiport || 9000; 31 | this.PORT = opts.port || 5001; 32 | this.HOST = opts.host || '127.0.0.1'; 33 | this.UPDATE_FREQ = opts.updateFreq || 20000; 34 | this.TIMEOUT = opts.updateTimeout || 2500; 35 | this.SEC_TO_EXPIRE = opts.secsToExpire || 60; 36 | this.MODE = opts.mode || 'http'; 37 | this.MY_IP = ip.address(); 38 | 39 | this.isOn = false; 40 | this.intents = []; 41 | this.regCache = {}; 42 | this.pending = {}; 43 | 44 | this.socket = null; 45 | }; 46 | 47 | util.inherits(Client, EventEmitter); 48 | 49 | /** 50 | * Create a new registration 51 | * 52 | * @param {String} name - Name of the service (preferably require('./package.json').name) 53 | * @param {String} version - Version of the service (preferably require('./package.json').version) 54 | * @param {String} port - Port this registration is bound to that clients can call 55 | * @param {Object} meta - Any additional meta data (key, value) to include 56 | */ 57 | 58 | Client.prototype.register = function(name, version, port, meta) { 59 | var self = this; 60 | var reg = { 61 | name: name, 62 | version: version, 63 | host: self.MY_IP, 64 | port: port, 65 | meta: meta || {} 66 | }; 67 | 68 | if (!reg.meta.hostname) { 69 | reg.meta.hostname = reg.host; 70 | } 71 | 72 | if (!reg.meta.secondsToExpire) { 73 | reg.meta.secondsToExpire = this.SEC_TO_EXPIRE; 74 | } 75 | 76 | reg.meta.hostname = os.hostname(); 77 | reg.meta.pid = process.pid; 78 | reg.meta.registered = Date.now(); 79 | 80 | var intent = registrations.create(reg); 81 | self.intents.push(intent); 82 | 83 | if (self.isOn) { 84 | self._sendUpdate(intent); 85 | } 86 | }; 87 | 88 | /** 89 | * Unregister a previously registered registration 90 | * 91 | * @param {String} name - Name of the service (preferably require('./package.json').name) 92 | * @param {String} version - Version of the service (preferably require('./package.json').version) 93 | * @param {String} port - Port this registration is bound to that clients can call 94 | */ 95 | 96 | Client.prototype.unregister = function(name, version, port) { 97 | var self = this; 98 | var host = self.MY_IP; 99 | var reg = registrations.create({ 100 | name: name, 101 | version: version, 102 | host: host, 103 | port: port, 104 | }); 105 | 106 | // 107 | // filter out the unwanted registration intent 108 | // 109 | self.intents = self.intents.filter(function (intent) { 110 | return reg.id !== intent.id; 111 | }); 112 | 113 | // 114 | // explicitely delete the registration so the offline notifications happen immediately 115 | // 116 | self.del(name, version, host, port); 117 | }; 118 | 119 | 120 | /** 121 | * Explicitly delete a registration 122 | * 123 | * @param {String} name - Name of the service 124 | * @param {String} version - Version of the service 125 | * @param {String} port - Port of the registration 126 | * @param {String} host - Host (ip) of the registration 127 | */ 128 | Client.prototype.del = function (name, version, host, port, cb) { 129 | var self = this; 130 | var uri = util.format('http://%s:%s/registrations/%s/%s/%s/%s', self.HOST, self.APIPORT, name, version, host, port); 131 | request({ 132 | uri: uri, 133 | json: true, 134 | method: 'DELETE' 135 | }, 136 | function (error, response, body) { 137 | if (error) self.log('error', 'Thalassa:Client.del', error); 138 | if (response && response.statusCode !== 200 && response.statusCode !== 404) { 139 | self.log('error', 'Thalassa:Client.del unexpected response ' + response.statusCode); 140 | error = new Error("del unexpected response " + response.statusCode); 141 | } 142 | if (typeof cb === 'function') cb(error); 143 | }); 144 | }; 145 | 146 | /** 147 | * Start polling, periodically checking the registrations into the Thalassa server 148 | */ 149 | 150 | Client.prototype.start = function() { 151 | var self = this; 152 | if (!self.isOn) { 153 | self.isOn = true; 154 | self._startUpdateInterval(); 155 | } 156 | }; 157 | 158 | /** 159 | * Stop polling registrations 160 | */ 161 | 162 | Client.prototype.stop = function() { 163 | var self = this; 164 | self.isOn = false; 165 | clearInterval(self._updateInterval); 166 | Object.keys(self.regCache).forEach(function (key) { 167 | var reg = self.regCache[key]; 168 | delete self.regCache[key]; 169 | self.unregister(reg.name,reg.version,reg.port); 170 | }); 171 | }; 172 | 173 | /** 174 | * Close: stop polling, disconnect socket 175 | */ 176 | 177 | Client.prototype.close = function() { 178 | var self = this; 179 | this.stop(); 180 | if (this.socket) this.socket.close(); 181 | }; 182 | 183 | /** 184 | * Find all `Registration`s over HTTP for `name` and `version` if provided. 185 | * 186 | * @param {String} [name] 187 | * @param {String} [version] 188 | * @param {getRegistrationsCallback} cb - Callback cb(error, registrations) 189 | * 190 | * @callback getRegistrationsCallback 191 | * @param {Error} error 192 | * @param {Registrations[]} registrations 193 | */ 194 | 195 | Client.prototype.getRegistrations = function(name, version, cb) { 196 | var self = this; 197 | 198 | var path; 199 | if (typeof name === 'function') { 200 | cb = name; 201 | path = '/registrations'; 202 | } 203 | else if (typeof version === 'function') { 204 | cb = version; 205 | path = util.format('/registrations/%s', name); 206 | } 207 | else if (typeof cb === 'function') { 208 | path = util.format('/registrations/%s/%s', name, version); 209 | } 210 | 211 | var uri = util.format('http://%s:%s%s', self.HOST, self.APIPORT, path); 212 | 213 | self.log('debug', 'Thalassa:Client.getRegistrations uri: ' + uri); 214 | 215 | request({ 216 | uri: uri, 217 | json: true 218 | }, 219 | function (error, response, body) { 220 | if (error) self.log('error', 'Thalassa:Client.getRegistrations', error); 221 | if (response && response.statusCode !== 200 && response.statusCode !== 404) { 222 | self.log('error', 'Thalassa:Client.getRegistrations unexpected response ' + response.statusCode); 223 | error = new Error("getRegistrations unexpected response " + response.statusCode); 224 | } 225 | if (error) return cb(error); 226 | 227 | var regs = (response.statusCode !== 200) ? [] : body; 228 | cb(null, regs); 229 | }); 230 | }; 231 | 232 | 233 | // 234 | // Axon socket functions 235 | // 236 | 237 | 238 | /** 239 | * Subscribe to `offline` and `online` events over the axon socket. Connects if not 240 | * already connected. 241 | * 242 | * @param {String} [name] 243 | * @param {String} [version] 244 | */ 245 | 246 | Client.prototype.subscribe = function(name, version) { 247 | if (this.socket === null) this.socketConnect(); 248 | var prefix = this._keySearch(name, version); 249 | this.log('debug', 'Thalassa:Client.subscribe ' + prefix); 250 | this.socket.subscribe(prefix); 251 | }; 252 | 253 | /** 254 | * Connect to the Thalassa Server axon socket if not already connected, typically called 255 | * the first time `subscribe` is called 256 | */ 257 | Client.prototype.socketConnect = function() { 258 | var self = this; 259 | if (this.socket === null) { 260 | this.socket = axon.socket('sub'); 261 | this.socket.set('identity', this.MY_IP + ':' + this.PORT); 262 | self.log('info', 'Thalassa:Client.socketConnect: connecting to socket ' + this.HOST + ':' + this.PORT); 263 | this.socket.connect(this.PORT, this.HOST); 264 | 265 | this.socket.on('message', function (regId, state, reg) { 266 | regId = regId.toString(); 267 | state = state.toString(); 268 | 269 | if (state === 'online') { 270 | var updatedReg = JSON.parse(reg); 271 | if(regId in self.regCache){ 272 | //if we already have the registration, and no metadata changed, do nothing 273 | if(deepEqual(updatedReg.meta, self.regCache[regId].meta)) 274 | return; 275 | } 276 | self.regCache[regId] = updatedReg; 277 | self.emit('online', registrations.parse(reg.toString())); 278 | } 279 | else if (state === 'offline') { 280 | if(regId in self.regCache) delete self.regCache[regId]; 281 | self.emit('offline', regId); 282 | } 283 | else { 284 | self.log('info', 'Thalassa:Client.onMessage: ignoring message, unknown state ', arguments.map(function (b) { b.toString(); })); 285 | } 286 | }); 287 | } 288 | }; 289 | 290 | Client.prototype._keySearch = function(name, version) { 291 | var keySearch = (name) ? ('/' + name) : ''; 292 | keySearch += (version) ? util.format('/%s/*', version) : '/*'; 293 | return keySearch; 294 | }; 295 | 296 | Client.prototype._startUpdateInterval = function() { 297 | var self = this; 298 | update(); 299 | self._updateInterval = setInterval(update, self.UPDATE_FREQ); 300 | 301 | function update () { 302 | self.intents.forEach(self._sendUpdate.bind(self)); 303 | } 304 | }; 305 | 306 | Client.prototype._sendUpdate = function (intent) { 307 | if (this.MODE === 'http') { 308 | this._sendHTTPUpdate(intent); 309 | } 310 | else { 311 | this.log('error', 'Thalassa:Client._sendUpdate: unsupported mode ' + this.mode); 312 | } 313 | }; 314 | 315 | 316 | Client.prototype._sendHTTPUpdate = function (intent) { 317 | // TODO batch multiple requests? 318 | var self = this; 319 | var uri = util.format('http://%s:%s/registrations/%s/%s/%s/%s', self.HOST, self.APIPORT, intent.name, intent.version, intent.host, intent.port); 320 | var startTime = Date.now(); 321 | 322 | // 323 | // If the last call is stil pending, don't add fuel to the fire 324 | // 325 | if (self.pending[intent.id]) { 326 | self.log('error', 'Thalassa:Client._sendHTTPUpdate last call still pending! (skipping): ' + intent.id); 327 | return ; 328 | } 329 | 330 | self.pending[intent.id] = true; 331 | 332 | request({ 333 | uri: uri, 334 | method: 'POST', 335 | json: intent.meta, 336 | timeout: self.TIMEOUT 337 | }, 338 | function (error, response, body) { 339 | self.pending[intent.id] = false; 340 | 341 | // Check for an unsuccessful response, in case, for example we call the wrong 342 | // host and it returns a 404, etc. 343 | if (!error && response.statusCode !== 200) { 344 | error = new Error(util.format('unexpected response statusCode %s from %s', 345 | response.statusCode, uri)); 346 | } 347 | 348 | if (error) { 349 | self.log('error', 'Thalassa:Client._sendUpdate', error); 350 | self.emit('updateFailed', error); 351 | } 352 | else { 353 | self.emit('updateSuccessful'); 354 | self.log('debug', util.format('Thalassa:Client._sendUpdate (%s) [%s] %s', response.statusCode, Date.now() - startTime, uri)); 355 | } 356 | }); 357 | }; 358 | -------------------------------------------------------------------------------- /lib/defaultLogger.js: -------------------------------------------------------------------------------- 1 | var clc = require('cli-color') 2 | ; 3 | 4 | var levelColors = { debug: clc.blue, info: clc.yellow, error: clc.red }; 5 | var levels = { debug: 0, info: 1, error: 2 }; 6 | var sep = ' - '; 7 | 8 | function printLevel(level) { 9 | return (levelColors[level] || clc.white)(level) + sep; 10 | } 11 | 12 | module.exports = function defaultLogger (filterLevel) { 13 | return function (level, message, meta) { 14 | if (levels[level] >= levels[filterLevel]) { 15 | var optMeta = (meta) ? sep + JSON.stringify(meta) /*require('util').inspect(meta, { depth: 5 })*/ : ''; 16 | timestamp = '['+ new Date().toISOString() +']'; 17 | 18 | console.log(timestamp + sep + printLevel(level) + message + optMeta); 19 | } 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /lib/server/RedisData.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis") 2 | , util = require("util") 3 | , events = require("events") 4 | , registrations = require('thalassa-registrations') 5 | , stats = require('./stats') 6 | ; 7 | 8 | var RedisData = module.exports = function RedisData (opts) { 9 | events.EventEmitter.call(this); 10 | 11 | if (!opts) opts = {}; var self = this; 12 | this.log = (typeof opts.log === 'function') ? opts.log : noop; 13 | 14 | this.SECONDS_TO_EXPIRE = opts.secondsToExpire || 10; 15 | this.REGISTRATIONS_SET_KEY = '__thalassa.registrations'; 16 | this.REDIS_HOST = opts.redisHost || '127.0.0.1'; 17 | this.REDIS_PORT = opts.redisPort || 6379; 18 | this.REDIS_DB = opts.redisDatabase || 0; 19 | 20 | this.redisClient = redis.createClient(this.REDIS_PORT, this.REDIS_HOST); 21 | this.redisClient.select(this.REDIS_DB); 22 | }; 23 | 24 | util.inherits(RedisData, events.EventEmitter); 25 | 26 | /** 27 | * Update or create a registration 28 | * 29 | * @param {Registration} reg 30 | * @param {number} [secondsToExpire=opts.secondsToExpire] 31 | * @param {Function} cb - Callback cb(error) 32 | */ 33 | 34 | RedisData.prototype.update = function update(reg, secondsToExpire, cb) { 35 | var self = this; 36 | if (typeof secondsToExpire === 'function' && cb === undefined) { 37 | cb = secondsToExpire; 38 | secondsToExpire = self.SECONDS_TO_EXPIRE; 39 | } 40 | else if (!secondsToExpire) { 41 | secondsToExpire = self.SECONDS_TO_EXPIRE; 42 | } 43 | cb = callback(cb); 44 | 45 | stats.meter('registrationsPerSecond').mark(); 46 | 47 | var registration = registrations.create(reg); 48 | 49 | var client = this.redisClient; 50 | var timeToExpire = Date.now() + ((secondsToExpire) * 1000); 51 | 52 | client.multi() 53 | // 54 | // set the registration details to a key by id 55 | // /name/version/host/port 56 | // 57 | .set(registration.id, registration.stringify()) 58 | // 59 | // add the timeToExpire to the registrations sorted set 60 | // 61 | .zadd(self.REGISTRATIONS_SET_KEY, timeToExpire, registration.id, function (error, numNew) { 62 | //self.log('debug', 'RedisData.update ' + registration.id + ' ' + timeToExpire); 63 | self.emit('online', registration); 64 | }) 65 | .exec(function (error, replies) { 66 | //self.log('debug', 'RedisData.update redis multi replies', replies); 67 | cb(error); 68 | }); 69 | }; 70 | 71 | /** 72 | * Explicitly delete a registration 73 | * 74 | * @param {String} regId 75 | * @param {Function} cb - Callback cb(error) 76 | */ 77 | 78 | RedisData.prototype.del = function del(regId, cb) { 79 | cb = callback(cb); 80 | var self = this; 81 | var client = this.redisClient; 82 | client.multi() 83 | .del(regId) 84 | .zrem(this.REGISTRATIONS_SET_KEY, regId) 85 | .exec(function (error, replies) { 86 | self.emit('offline', regId); 87 | cb(error); 88 | }); 89 | }; 90 | 91 | /** 92 | * Find all `Registration`s for `name` and `version` if provided. 93 | * 94 | * @param {String} [name] 95 | * @param {String} [version] 96 | * @param {getRegistrationsCallback} cb - Callback cb(error, registrations) 97 | * 98 | * @callback getRegistrationsCallback 99 | * @param {Error} error 100 | * @param {Registrations[]} registrations 101 | */ 102 | 103 | RedisData.prototype.getRegistrations = function getRegistrations(name, version, cb) { 104 | 105 | /** 106 | */ 107 | var keySearch; 108 | if (typeof name === 'function') { 109 | cb = name; 110 | keySearch = '/*'; 111 | } 112 | else if (typeof version === 'function') { 113 | cb = version; 114 | keySearch = util.format('/%s/*', name); 115 | } 116 | else if (typeof cb === 'function') { 117 | keySearch = util.format('/%s/%s/*', name, version); 118 | } 119 | 120 | cb = callback(cb); 121 | this._getRegistrations(keySearch, cb); 122 | }; 123 | 124 | /** 125 | * Find all `Registration`s for a `keySearch` prefix 126 | * 127 | * @api private 128 | * @param {String} keySearch - Redis `regId` key prefix to search for registrations 129 | * @param {_getRegistrationsCallback} cb - Callback cb(error, registrations) 130 | * 131 | * @callback _getRegistrationsCallback 132 | * @param {Error} error 133 | * @param {Registrations[]} registrations 134 | */ 135 | 136 | RedisData.prototype._getRegistrations = function _getRegistrations(keySearch, cb) { 137 | var self = this; 138 | var client = this.redisClient; 139 | 140 | client.keys(keySearch, function (error, ids) { 141 | if (error) return cb(error); 142 | 143 | if (!ids || ids.length === 0) return cb(null, []); 144 | var regIds = ids.filter(function (id) { return registrations.isRegistrationId(id); }); 145 | if (regIds.length === 0) return cb(null, []); 146 | 147 | client.mget(ids, function (error, stringifiedRegs) { 148 | if (error) return cb(error); 149 | var regs = stringifiedRegs 150 | .map(function(stringifiedReg) { return registrations.parse(stringifiedReg); }); 151 | cb(null, regs); 152 | }); 153 | }); 154 | }; 155 | 156 | /** 157 | * Run of the timeout logic: the "reaper" 158 | * 159 | * @param {runReaperCallback} cb - Callback cb(error, reaped) 160 | * 161 | * @callback runReaperCallback 162 | * @param {Error} error 163 | * @param {String[]} reaped - Array of `regId`s that have been reaped 164 | */ 165 | 166 | RedisData.prototype.runReaper = function runReaper(cb) { 167 | cb = callback(cb); 168 | var self = this; 169 | var client = this.redisClient; 170 | 171 | var reaperScript = "\ 172 | local res = redis.call('ZRANGEBYSCORE',KEYS[1], 0, ARGV[1], 'LIMIT', 0, 100 ) \ 173 | if #res > 0 then \ 174 | redis.call( 'ZREMRANGEBYRANK', KEYS[1], 0, #res-1 ) \ 175 | return res \ 176 | else \ 177 | return false \ 178 | end"; 179 | 180 | function evalCallback (error, reaped) { 181 | if (error) self.log('error', error.message || error); 182 | if (!reaped) reaped = []; 183 | 184 | reaped.forEach(function (regId) { 185 | self.del(regId); 186 | }); 187 | 188 | if (reaped.length > 0) { 189 | self.log('debug', 'RedisData.runReaper: reaped ' + reaped.length + ' registrations', reaped); 190 | } 191 | 192 | cb(error, reaped); 193 | } 194 | 195 | // the redis client isn't accepting an array of arguments for 196 | // eval for some reason. This doesn't look as nice, but works 197 | client.EVAL.apply(client, [reaperScript, 1, self.REGISTRATIONS_SET_KEY, Date.now(), evalCallback]); 198 | }; 199 | 200 | /** 201 | * Clear the entire Redis database !!! 202 | * 203 | * @param {clearDbCallback} cb - Callback cb(error) 204 | * 205 | * @callback clearDbCallback 206 | * @param {Error} error 207 | */ 208 | 209 | RedisData.prototype.clearDb = function clearDb(cb) { 210 | this.redisClient.flushdb(callback(cb)); 211 | }; 212 | 213 | function callback(cb) { 214 | return (typeof cb === 'function') ? cb : noop; 215 | } 216 | 217 | function noop() { 218 | } 219 | -------------------------------------------------------------------------------- /lib/server/Server.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter 2 | , Hapi = require('hapi') 3 | , Client = require('../client/Client') 4 | , RedisData = require('./RedisData') 5 | , axon = require('axon') 6 | , ip = require('ip') 7 | , pkg = require('../../package.json') 8 | , util = require('util') 9 | , stats = require('./stats') 10 | ; 11 | 12 | /** 13 | * `Server` constructor 14 | * @constructor 15 | * 16 | * @param {number} [opts.port=5001] - Port number of axon socket port 17 | * @param {number} [opts.apiport=9000] - Port number of Thalassa HTTP API 18 | * @param {number} [opts.reaperFreq=2000] - How often to run the reaper (ms) 19 | * @param {number} [opts.updateFreq=30000] - How often to check own registration in 20 | */ 21 | 22 | var Server = module.exports = function (opts) { 23 | var self = this; 24 | if (typeof opts !== 'object') opts = {}; 25 | this.log = (typeof opts.log === 'function') ? opts.log : function (){}; 26 | 27 | this.PORT = opts.port || 5001; 28 | this.IP = ip.address(); 29 | this.API_PORT = opts.apiport || 9000; 30 | this.REAPER_FREQ = opts.reaperFreq || 2000; 31 | this.UPDATE_FREQ = opts.updateFreq || 30000; 32 | 33 | this.pub = axon.socket('pub'); 34 | 35 | var me = { 36 | name: pkg.name, 37 | version: pkg.version, 38 | host: self.IP, 39 | port: self.API_PORT, 40 | meta: { 41 | hostname: require('os').hostname, 42 | axonSocket: self.PORT 43 | } 44 | }; 45 | 46 | var secondsToExpire = Math.ceil((this.UPDATE_FREQ / 1000) * 2); 47 | 48 | // 49 | // Connect to Redis, register yourself, and do so regularly 50 | // 51 | this.data = new RedisData(opts); 52 | self.data.update(me, secondsToExpire); 53 | this._updateInterval = setInterval(function () { 54 | self.data.update(me, secondsToExpire); 55 | }, this.UPDATE_FREQ); 56 | 57 | // 58 | // API server 59 | // 60 | this.apiServer = new Hapi.Server(this.API_PORT); 61 | require('./httpApi')(this); 62 | this.apiServer.start(function () { 63 | self.log('info', util.format("Thalassa API HTTP server listening on %s", self.API_PORT)); 64 | }); 65 | 66 | // 67 | // Schedule the Reaper! 68 | // 69 | this._reaperInterval = setInterval(function () { 70 | self.data.runReaper(); 71 | }, this.REAPER_FREQ); 72 | 73 | // 74 | // Setup Publisher Socket 75 | // 76 | self.pub.set('identity', 'thalassa|' + this.IP + ':' + this.PORT); 77 | self.log('debug', util.format("attempting to bind to %s", this.PORT)); 78 | self.pub.bind(this.PORT); 79 | this.data.on('online', onOnline); 80 | this.data.on('offline', onOffline); 81 | self.log('info', util.format("Thalassa socket server listening at %s", this.PORT)); 82 | 83 | // 84 | // At startup, publish `online` for all existing registrations. 85 | // Do this before the reaper interval runs the first time. By then hopefully clients 86 | // will have had the opportunity to check back in if Thalassa was down and no other 87 | // Thalassa server was running, reaping and serving check ins 88 | // 89 | self.log('debug', "Publishing 'online' for all known registrations"); 90 | this.data.getRegistrations(function (err, regs) { 91 | if (err) { 92 | // this is not good, error, exit the process and better luck next time 93 | self.log('error', 'getRegistrations failed on initialization, exiting process', String(err)); 94 | return process.exit(1); 95 | } 96 | self.log('debug', regs.length + ' known instances on startup'); 97 | // regs.forEach(function (reg) { 98 | // onOnline(reg); 99 | // }); 100 | }); 101 | 102 | function onOnline (reg) { 103 | self.log('debug', 'socket published ', [reg.id, 'online', reg]); 104 | self.pub.send(reg.id, 'online', reg.stringify()); 105 | } 106 | 107 | function onOffline(regId) { 108 | self.log('debug', 'socket published ', [regId, 'offline']); 109 | self.pub.send(regId, 'offline'); 110 | } 111 | 112 | }; 113 | 114 | /** 115 | * Cleanup and close out all connection, primarily for testing 116 | */ 117 | 118 | Server.prototype.close = function() { 119 | this.apiServer.stop(); 120 | clearInterval(this._updateInterval); 121 | clearInterval(this._reaperInterval); 122 | this.data.redisClient.end(); 123 | this.pub.close(); 124 | }; 125 | -------------------------------------------------------------------------------- /lib/server/httpApi.js: -------------------------------------------------------------------------------- 1 | var registrations = require('thalassa-registrations') 2 | , stats = require('./stats') 3 | , pjson = require('../../package.json') 4 | ; 5 | 6 | module.exports = function (server) { 7 | 8 | server.apiServer.route({ 9 | method: 'GET', 10 | path: '/registrations/{name}/{version}', 11 | handler: function (request, reply) { 12 | var name = this.params.name; 13 | var version = this.params.version; 14 | server.data.getRegistrations(name, version, function (err, registrations) { 15 | reply(registrations); 16 | }); 17 | } 18 | }); 19 | 20 | server.apiServer.route({ 21 | method: 'GET', 22 | path: '/registrations/{name}', 23 | handler: function (request, reply) { 24 | var name = this.params.name; 25 | server.data.getRegistrations(name, function (err, registrations) { 26 | reply(registrations); 27 | }); 28 | } 29 | }); 30 | 31 | server.apiServer.route({ 32 | method: 'GET', 33 | path: '/registrations', 34 | handler: function (request, reply) { 35 | server.data.getRegistrations(function (err, registrations) { 36 | reply(registrations); 37 | }); 38 | } 39 | }); 40 | 41 | server.apiServer.route({ 42 | method: 'POST', 43 | path: '/registrations/{name}/{version}/{host}/{port}', 44 | handler: function (request, reply) { 45 | var reg = { 46 | name: this.params.name, 47 | version: this.params.version, 48 | host: this.params.host, 49 | port: this.params.port, 50 | meta: request.payload || {} 51 | }; 52 | 53 | server.data.update(reg, reg.meta.secondsToExpire, function (err) { 54 | if (err) reply(err); 55 | else reply(200); 56 | }); 57 | } 58 | }); 59 | 60 | server.apiServer.route({ 61 | method: 'DELETE', 62 | path: '/registrations/{name}/{version}/{host}/{port}', 63 | handler: function (request, reply) { 64 | var reg = { 65 | name: this.params.name, 66 | version: this.params.version, 67 | host: this.params.host, 68 | port: this.params.port 69 | }; 70 | 71 | var regId = registrations.create(reg).id; 72 | 73 | server.data.del(regId, function (err) { 74 | if (err) reply(err); 75 | else reply(200); 76 | }); 77 | } 78 | }); 79 | 80 | server.apiServer.route({ 81 | method: 'GET', 82 | path: '/status', 83 | handler: function (request, reply) { 84 | var status = { 85 | name: pjson.name, 86 | version: pjson.version, 87 | uptime: process.uptime(), 88 | memoryUsage: process.memoryUsage(), 89 | stats: stats.toJSON() 90 | }; 91 | 92 | reply(status); 93 | } 94 | }); 95 | }; 96 | -------------------------------------------------------------------------------- /lib/server/stats.js: -------------------------------------------------------------------------------- 1 | var measured = require('measured') 2 | , stats = new measured.Collection('thalassa'); 3 | 4 | module.exports = stats; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thalassa", 3 | "version": "0.4.3", 4 | "description": "A lightweight service registry using Redis inspired by Seaport", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jshint *.js lib/**/*.js test/*.js --config .jshintrc && mocha test/test*" 8 | }, 9 | "bin": { 10 | "thalassa-server": "./bin/server.js", 11 | "thalassa-client": "./bin/client.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/PearsonEducation/Thalassa.git" 16 | }, 17 | "keywords": [ 18 | "service", 19 | "registry", 20 | "haproxy" 21 | ], 22 | "author": "Mike Brevoort ", 23 | "license": "Apache2", 24 | "readmeFilename": "README.md", 25 | "bugs": { 26 | "url": "https://github.com/PearsonEducation/Thalassa/issues" 27 | }, 28 | "devDependencies": { 29 | "mocha": "~1.12.0", 30 | "jshint": "~2.1.4", 31 | "portfinder": "~0.2.1", 32 | "request": "~2.21.0" 33 | }, 34 | "optionalDependencies": { 35 | "hiredis": "~0.1.15" 36 | }, 37 | "dependencies": { 38 | "hapi": "~8.0.0", 39 | "optimist": "~0.6.0", 40 | "cli-color": "~0.2.2", 41 | "redis": "~0.8.4", 42 | "ip": "~0.1.0", 43 | "request": "~2.27.0", 44 | "thalassa-registrations": "~0.1.0", 45 | "axon": "~1.0.0", 46 | "measured": "^0.1.3", 47 | "deep-equal": "^0.2.1" 48 | }, 49 | "engines": { 50 | "node": ">0.10.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/test.data.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , path = require('path') 3 | , f = require('util').format 4 | , RedisData = require('../lib/server/RedisData') 5 | ; 6 | 7 | 8 | describe ('Data Module', function () { 9 | describe ('Redis', function () { 10 | var data = null; 11 | 12 | before(function (done) { 13 | data = new RedisData({ redisDatabase: 15}); 14 | data.clearDb(done); 15 | }); 16 | 17 | it ('should create and delete registration explicitly', function (done) { 18 | const EXPECTED_STEPS = 3; 19 | var steps =[]; 20 | var reg = { 21 | name: 'foo', 22 | version: '1.0.0', 23 | host: '127.0.0.1', 24 | port: 8080 25 | }; 26 | 27 | var expectedRegId = f('/%s/%s/%s/%s', reg.name, reg.version, reg.host, reg.port); 28 | 29 | data.on('online', function (onlineReg) { 30 | if (onlineReg.name === reg.name) { 31 | assert.equal(onlineReg.version, reg.version); 32 | assert.equal(onlineReg.host, reg.host); 33 | assert.equal(onlineReg.port, reg.port); 34 | assert.equal(onlineReg.id, expectedRegId); 35 | ifDone('onlineEvent'); 36 | } 37 | }); 38 | 39 | data.on('offline', function (regId) { 40 | if (regId === expectedRegId) { 41 | ifDone('offlineEvent'); 42 | } 43 | }); 44 | 45 | data.update(reg, function (err) { 46 | assert.ifError(err); 47 | 48 | data.getRegistrations(reg.name, reg.version, function (err, registrations) { 49 | assert.ifError(err); 50 | assert.equal(registrations.length, 1); 51 | assert.equal(registrations[0].name, reg.name); 52 | assert.equal(registrations[0].version, reg.version); 53 | assert.equal(registrations[0].host, reg.host); 54 | assert.equal(registrations[0].port, reg.port); 55 | 56 | data.del(registrations[0].id, function (err) { 57 | assert.ifError(err); 58 | ifDone('delete'); 59 | }); 60 | }); 61 | }); 62 | 63 | function ifDone(name) { 64 | steps.push(name); 65 | if (steps.length == EXPECTED_STEPS) done(); 66 | } 67 | }); 68 | 69 | it ('should create and delete registration explicitly', function (done) { 70 | const EXPECTED_STEPS = 2; 71 | var steps = []; 72 | var reg = { 73 | name: 'bar', 74 | version: '1.0.0', 75 | host: '127.0.0.1', 76 | port: 8080 77 | }; 78 | 79 | var expectedRegId = f('/%s/%s/%s/%s', reg.name, reg.version, reg.host, reg.port); 80 | 81 | data.on('offline', function (regId) { 82 | if (regId === expectedRegId) { 83 | ifDone('offlineEvent'); 84 | } 85 | }); 86 | 87 | data.update(reg, 1, function (err) { 88 | assert.ifError(err); 89 | 90 | data.getRegistrations(reg.name, reg.version, function (err, registrations) { 91 | assert.ifError(err); 92 | assert.equal(registrations.length, 1); 93 | assert.equal(registrations[0].name, reg.name); 94 | assert.equal(registrations[0].version, reg.version); 95 | assert.equal(registrations[0].host, reg.host); 96 | assert.equal(registrations[0].port, reg.port); 97 | 98 | setTimeout(function() { 99 | data.runReaper(function (err, reapedIds) { 100 | assert.ifError(err); 101 | assert.equal(reapedIds.length, 1); 102 | assert.equal(reapedIds[0], expectedRegId); 103 | ifDone('reaper'); 104 | }); 105 | }, 1100); 106 | }); 107 | }); 108 | 109 | function ifDone(name) { 110 | steps.push(name); 111 | if (steps.length == EXPECTED_STEPS) done(); 112 | } 113 | }); 114 | }); 115 | }); -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , path = require('path') 3 | , request = require('request') 4 | , portfinder = require('portfinder') 5 | , Server = require('..').Server 6 | , Client = require('..').Client 7 | ; 8 | 9 | 10 | describe ('Thalassa', function () { 11 | 12 | describe ('api', function () { 13 | 14 | var localhost = '127.0.0.1' 15 | , PORT = null 16 | , HOST = localhost 17 | , API_PORT = null 18 | , API_HOST = localhost 19 | , server = null 20 | , apiRoot = null 21 | ; 22 | 23 | before (function (done) { 24 | 25 | portfinder.getPort(function (err, port) { 26 | assert.ifError(err); 27 | PORT = port; 28 | portfinder.basePort = 9000; 29 | portfinder.getPort(function (err, port) { 30 | assert.ifError(err); 31 | API_PORT = port; 32 | apiRoot = 'http://' + API_HOST + ':' + API_PORT; 33 | server = new Server( { 34 | port: PORT, 35 | host: HOST, 36 | apiport: API_PORT, 37 | apihost: API_HOST, 38 | reaperFreq: 100 39 | }); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | 45 | after (function () { 46 | server.close(); 47 | }); 48 | 49 | it ('should return all for registrations', function (done) { 50 | this.timeout(10000); 51 | 52 | //need to wait until it comes up 53 | setTimeout(function () { 54 | request({ 55 | uri: apiRoot + '/registrations', 56 | json: true 57 | }, function (error, response, body) { 58 | assert.ifError(error); 59 | assert.equal(200, response.statusCode); 60 | 61 | var thalassas = body.filter(function (it) { return (it.name === 'thalassa'); }); 62 | 63 | assert.equal(1, thalassas.length); 64 | done(); 65 | }); 66 | }, 100); 67 | }); 68 | 69 | it ('should 404 for unknown route', function (done) { 70 | this.timeout(5000); 71 | 72 | //need to wait until it comes up 73 | setTimeout(function () { 74 | request({ 75 | uri: apiRoot + '/bogus', 76 | json: true 77 | }, function (error, response, body) { 78 | assert.ifError(error); 79 | assert.equal(404, response.statusCode); 80 | done(); 81 | }); 82 | }, 100); 83 | }); 84 | 85 | it ('should return registrations by name', function (done) { 86 | this.timeout(5000); 87 | 88 | //need to wait until it comes up 89 | setTimeout(function () { 90 | request({ 91 | uri: apiRoot + '/registrations/thalassa', 92 | json: true 93 | }, function (error, response, body) { 94 | assert.ifError(error); 95 | assert.equal(200, response.statusCode); 96 | 97 | assert.equal(1, body.length); 98 | assert.equal('thalassa', body[0].name); 99 | done(); 100 | }); 101 | }, 100); 102 | }); 103 | 104 | it ('should return registrations by name and version', function (done) { 105 | this.timeout(5000); 106 | var version = require('../package.json').version; 107 | 108 | //need to wait until it comes up 109 | setTimeout(function () { 110 | request({ 111 | uri: apiRoot + '/registrations/thalassa/'+version, 112 | json: true 113 | }, function (error, response, body) { 114 | assert.ifError(error); 115 | assert.equal(200, response.statusCode); 116 | 117 | assert.equal(1, body.length); 118 | assert.equal('thalassa', body[0].name); 119 | assert.equal(version, body[0].version); 120 | done(); 121 | }); 122 | }, 100); 123 | }); 124 | 125 | it ('should return nothing for query by unknown name', function (done) { 126 | this.timeout(5000); 127 | 128 | //need to wait until it comes up 129 | setTimeout(function () { 130 | request({ 131 | uri: apiRoot + '/registrations/bogus', 132 | json: true 133 | }, function (error, response, body) { 134 | assert.ifError(error); 135 | assert.equal(200, response.statusCode); 136 | assert.equal(0, body.length); 137 | done(); 138 | }); 139 | }, 100); 140 | }); 141 | 142 | it( 'should register and receive one online event if metadata stays the same', function (done) { 143 | this.timeout(5000); 144 | var eventCount = 0; 145 | var name = 'foobar' 146 | , version = '1.0.0' 147 | , host = '10.10.10.10' 148 | , port = 8411 149 | , meta = { myMeta: 'foo', myOtherMeta: 1 } 150 | ; 151 | var client = new Client({ 152 | host: HOST, 153 | port: PORT, 154 | updateFreq: 2000 155 | }); 156 | client.on('online', function(reg){ 157 | eventCount++; 158 | setInterval(function() { 159 | if(eventCount == 1){ 160 | assert.equal(reg.name,name); 161 | assert.equal(reg.version,version); 162 | assert.equal(reg.port, port); 163 | assert.deepEqual(reg.meta, meta); 164 | done(); 165 | } 166 | },3000); 167 | }); 168 | client.subscribe(name, version); 169 | client.register(name, version, port, meta); 170 | client.start(); 171 | }); 172 | 173 | it( 'should register, receive an online event, unregister, then receive another online event', function (done) { 174 | this.timeout(5000); 175 | var eventCount = 0; 176 | var offlineRecieved = false; 177 | var name = 'foobar' 178 | , version = '1.0.0' 179 | , host = '10.10.10.10' 180 | , port = 8411 181 | , meta = { myMeta: 'foo', myOtherMeta: 1 } 182 | ; 183 | var client = new Client({ 184 | host: HOST, 185 | port: PORT, 186 | updateFreq: 2000 187 | }); 188 | client.on('online', function(reg){ 189 | eventCount++; 190 | if(eventCount == 1){ 191 | assert.equal(reg.name,name); 192 | assert.equal(reg.version,version); 193 | assert.equal(reg.port, port); 194 | assert.deepEqual(reg.meta, meta); 195 | client.unregister(name,version,port); 196 | } 197 | else if(offlineReceived){ 198 | assert.equal(reg.name,name); 199 | assert.equal(reg.version,version); 200 | assert.equal(reg.port, port); 201 | done(); 202 | } 203 | }); 204 | client.on('offline', function(reg){ 205 | offlineReceived = true; 206 | }); 207 | client.subscribe(name, version); 208 | client.register(name, version, port, meta); 209 | client.start(); 210 | }); 211 | 212 | it ('should register and find custom registration', function (done) { 213 | this.timeout(5000); 214 | var name = 'foo' 215 | , version = '2.0.0' 216 | , host = '10.10.10.10' 217 | , port = 8411 218 | , meta = { myMeta: 'foo', myOtherMeta: 1 } 219 | ; 220 | var client = new Client({ 221 | host: HOST, 222 | port: PORT 223 | }); 224 | client.register(name, version, port, meta); 225 | client.start(); 226 | 227 | //need to wait until it comes up 228 | setTimeout(function () { 229 | request({ 230 | uri: apiRoot + '/registrations/' + name +'/'+version, 231 | json: true 232 | }, function (error, response, body) { 233 | assert.ifError(error); 234 | assert.equal(200, response.statusCode); 235 | 236 | assert.equal(1, body.length); 237 | assert.equal(name, body[0].name); 238 | assert.equal(version, body[0].version); 239 | assert.equal(port, body[0].port); 240 | assert.equal(meta.myMeta, body[0].meta.myMeta); 241 | assert.equal(meta.myOtherMeta, body[0].meta.myOtherMeta); 242 | 243 | //also test get registrations 244 | client.getRegistrations(name, version, function(err,regs){ 245 | assert.ifError(err); 246 | assert.equal(regs.length, 1); 247 | done(); 248 | }); 249 | }); 250 | }, 100); 251 | }); 252 | 253 | it ('should stop and restart properly', function (done) { 254 | this.timeout(5000); 255 | var name = 'bar' 256 | , version = '2.0.0' 257 | , host = '10.10.10.10' 258 | , port = 8412 259 | , secondsToExpire = 1 260 | ; 261 | var client = new Client({ 262 | host: HOST, 263 | port: PORT 264 | }); 265 | 266 | client.register(name, version, port, { secondsToExpire: secondsToExpire}); 267 | setTimeout(function() { 268 | client.stop(); 269 | setTimeout(function() { 270 | request({ 271 | uri: apiRoot + '/registrations/' + name +'/'+version, 272 | json: true 273 | }, function (error, response, body) { 274 | assert.ifError(error); 275 | assert.equal(200, response.statusCode); 276 | assert.equal(0, body.length); 277 | 278 | client.start(); 279 | setTimeout(function() { 280 | request({ 281 | uri: apiRoot + '/registrations/' + name +'/'+version, 282 | json: true 283 | }, function (error, response, body) { 284 | assert.ifError(error); 285 | assert.equal(200, response.statusCode); 286 | 287 | assert.equal(1, body.length); 288 | assert.equal(name, body[0].name); 289 | assert.equal(version, body[0].version); 290 | assert.equal(port, body[0].port); 291 | done(); 292 | }); 293 | }, 100); 294 | }); 295 | }, secondsToExpire*1000); 296 | }, 100); 297 | }); 298 | 299 | it ('should register and unregister properly', function (done) { 300 | this.timeout(5000); 301 | var name = 'baz' 302 | , version = '3.0.0' 303 | , host = '10.10.10.10' 304 | , port = 8413 305 | ; 306 | var client = new Client({ 307 | host: HOST, 308 | port: PORT 309 | }); 310 | 311 | client.register(name, version, port); 312 | client.start(); 313 | 314 | setTimeout(function() { 315 | request({ 316 | uri: apiRoot + '/registrations/' + name +'/'+version, 317 | json: true 318 | }, function (error, response, body) { 319 | assert.ifError(error); 320 | assert.equal(200, response.statusCode); 321 | 322 | assert.equal(1, body.length); 323 | assert.equal(name, body[0].name); 324 | assert.equal(version, body[0].version); 325 | assert.equal(port, body[0].port); 326 | client.unregister(name, version, port); 327 | setTimeout(function() { 328 | request({ 329 | uri: apiRoot + '/registrations/' + name +'/'+version, 330 | json: true 331 | }, function (error, response, body) { 332 | assert.ifError(error); 333 | assert.equal(200, response.statusCode); 334 | assert.equal(0, body.length); 335 | done(); 336 | }); 337 | }, 100); 338 | }); 339 | }, 100); 340 | }); 341 | }); 342 | }); 343 | --------------------------------------------------------------------------------