├── .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 | [](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 |
--------------------------------------------------------------------------------