@hazeljs/discovery 0.2.0-beta.16 → 0.2.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -19
- package/dist/__tests__/consul-backend.test.d.ts +6 -0
- package/dist/__tests__/consul-backend.test.d.ts.map +1 -0
- package/dist/__tests__/consul-backend.test.js +300 -0
- package/dist/__tests__/kubernetes-backend.test.d.ts +6 -0
- package/dist/__tests__/kubernetes-backend.test.d.ts.map +1 -0
- package/dist/__tests__/kubernetes-backend.test.js +261 -0
- package/dist/__tests__/redis-backend.test.d.ts +6 -0
- package/dist/__tests__/redis-backend.test.d.ts.map +1 -0
- package/dist/__tests__/redis-backend.test.js +280 -0
- package/dist/backends/consul-backend.d.ts +46 -7
- package/dist/backends/consul-backend.d.ts.map +1 -1
- package/dist/backends/consul-backend.js +23 -39
- package/dist/backends/kubernetes-backend.d.ts +44 -6
- package/dist/backends/kubernetes-backend.d.ts.map +1 -1
- package/dist/backends/kubernetes-backend.js +11 -32
- package/dist/backends/memory-backend.d.ts +0 -1
- package/dist/backends/memory-backend.d.ts.map +1 -1
- package/dist/backends/memory-backend.js +3 -32
- package/dist/backends/redis-backend.d.ts +11 -6
- package/dist/backends/redis-backend.d.ts.map +1 -1
- package/dist/backends/redis-backend.js +66 -46
- package/dist/client/discovery-client.d.ts +6 -4
- package/dist/client/discovery-client.d.ts.map +1 -1
- package/dist/client/discovery-client.js +30 -30
- package/dist/client/service-client.d.ts.map +1 -1
- package/dist/client/service-client.js +82 -17
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/registry/service-registry.d.ts.map +1 -1
- package/dist/registry/service-registry.js +13 -2
- package/dist/utils/filter.d.ts +10 -0
- package/dist/utils/filter.d.ts.map +1 -0
- package/dist/utils/filter.js +39 -0
- package/dist/utils/logger.d.ts +21 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +34 -0
- package/dist/utils/validation.d.ts +36 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +109 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -7,14 +7,18 @@ Service Discovery and Registry for HazelJS microservices - inspired by Netflix E
|
|
|
7
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
10
|
+
- **Service Registration & Discovery** - Automatic service registration with health checks
|
|
11
|
+
- **Load Balancing** - 6 built-in strategies (Round Robin, Random, Least Connections, Weighted Round Robin, IP Hash, Zone Aware)
|
|
12
|
+
- **Health Checks** - Automatic health monitoring with heartbeat
|
|
13
|
+
- **Service Filtering** - Filter by zone, tags, metadata, and status
|
|
14
|
+
- **Multiple Backends** - Memory (dev), Redis, Consul, Kubernetes
|
|
15
|
+
- **Decorator Support** - Clean integration with HazelJS apps
|
|
16
|
+
- **Caching** - Built-in service discovery caching with auto-refresh
|
|
17
|
+
- **Auto-Cleanup** - Automatic removal of expired instances
|
|
18
|
+
- **Smart Retries** - Only retries on transient/network errors, not client errors
|
|
19
|
+
- **Pluggable Logging** - Bring your own logger or use the built-in console logger
|
|
20
|
+
- **Config Validation** - Runtime validation of all configuration objects
|
|
21
|
+
- **Graceful Shutdown** - Proper cleanup of intervals, connections, and resources
|
|
18
22
|
|
|
19
23
|
## Installation
|
|
20
24
|
|
|
@@ -22,6 +26,21 @@ Service Discovery and Registry for HazelJS microservices - inspired by Netflix E
|
|
|
22
26
|
npm install @hazeljs/discovery
|
|
23
27
|
```
|
|
24
28
|
|
|
29
|
+
### Optional peer dependencies
|
|
30
|
+
|
|
31
|
+
Install the backend you need:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Redis backend
|
|
35
|
+
npm install ioredis
|
|
36
|
+
|
|
37
|
+
# Consul backend
|
|
38
|
+
npm install consul
|
|
39
|
+
|
|
40
|
+
# Kubernetes backend
|
|
41
|
+
npm install @kubernetes/client-node
|
|
42
|
+
```
|
|
43
|
+
|
|
25
44
|
## Quick Start
|
|
26
45
|
|
|
27
46
|
### 1. Register a Service
|
|
@@ -41,6 +60,9 @@ const registry = new ServiceRegistry({
|
|
|
41
60
|
});
|
|
42
61
|
|
|
43
62
|
await registry.register();
|
|
63
|
+
|
|
64
|
+
// On shutdown
|
|
65
|
+
await registry.deregister();
|
|
44
66
|
```
|
|
45
67
|
|
|
46
68
|
### 2. Discover Services
|
|
@@ -51,6 +73,7 @@ import { DiscoveryClient } from '@hazeljs/discovery';
|
|
|
51
73
|
const client = new DiscoveryClient({
|
|
52
74
|
cacheEnabled: true,
|
|
53
75
|
cacheTTL: 30000,
|
|
76
|
+
refreshInterval: 15000, // auto-refresh cache every 15s
|
|
54
77
|
});
|
|
55
78
|
|
|
56
79
|
// Get all instances
|
|
@@ -58,6 +81,9 @@ const instances = await client.getInstances('user-service');
|
|
|
58
81
|
|
|
59
82
|
// Get one instance with load balancing
|
|
60
83
|
const instance = await client.getInstance('user-service', 'round-robin');
|
|
84
|
+
|
|
85
|
+
// On shutdown
|
|
86
|
+
client.close();
|
|
61
87
|
```
|
|
62
88
|
|
|
63
89
|
### 3. Call Services
|
|
@@ -70,10 +96,12 @@ const serviceClient = new ServiceClient(discoveryClient, {
|
|
|
70
96
|
loadBalancingStrategy: 'round-robin',
|
|
71
97
|
timeout: 5000,
|
|
72
98
|
retries: 3,
|
|
99
|
+
retryDelay: 1000,
|
|
73
100
|
});
|
|
74
101
|
|
|
75
|
-
// Automatic service discovery + load balancing
|
|
102
|
+
// Automatic service discovery + load balancing + smart retries
|
|
76
103
|
const user = await serviceClient.get('/users/123');
|
|
104
|
+
const created = await serviceClient.post('/users', { name: 'John' });
|
|
77
105
|
```
|
|
78
106
|
|
|
79
107
|
### 4. With HazelJS Decorators
|
|
@@ -105,36 +133,44 @@ export class OrderService {
|
|
|
105
133
|
## Load Balancing Strategies
|
|
106
134
|
|
|
107
135
|
### Round Robin
|
|
136
|
+
|
|
108
137
|
```typescript
|
|
109
138
|
const instance = await client.getInstance('service-name', 'round-robin');
|
|
110
139
|
```
|
|
111
140
|
|
|
112
141
|
### Random
|
|
142
|
+
|
|
113
143
|
```typescript
|
|
114
144
|
const instance = await client.getInstance('service-name', 'random');
|
|
115
145
|
```
|
|
116
146
|
|
|
117
147
|
### Least Connections
|
|
148
|
+
|
|
149
|
+
Tracks active connections per instance. When used with `ServiceClient`, connection counts are automatically incremented/decremented on each request.
|
|
150
|
+
|
|
118
151
|
```typescript
|
|
119
152
|
const instance = await client.getInstance('service-name', 'least-connections');
|
|
120
153
|
```
|
|
121
154
|
|
|
122
155
|
### Weighted Round Robin
|
|
156
|
+
|
|
123
157
|
```typescript
|
|
124
158
|
// Set weight in service metadata
|
|
125
159
|
const registry = new ServiceRegistry({
|
|
126
160
|
name: 'api-service',
|
|
161
|
+
port: 3000,
|
|
127
162
|
metadata: { weight: 5 }, // Higher weight = more traffic
|
|
128
|
-
// ...
|
|
129
163
|
});
|
|
130
164
|
```
|
|
131
165
|
|
|
132
166
|
### IP Hash (Sticky Sessions)
|
|
167
|
+
|
|
133
168
|
```typescript
|
|
134
169
|
const instance = await client.getInstance('service-name', 'ip-hash');
|
|
135
170
|
```
|
|
136
171
|
|
|
137
172
|
### Zone Aware
|
|
173
|
+
|
|
138
174
|
```typescript
|
|
139
175
|
const factory = client.getLoadBalancerFactory();
|
|
140
176
|
const strategy = factory.create('zone-aware', { zone: 'us-east-1' });
|
|
@@ -143,6 +179,8 @@ const strategy = factory.create('zone-aware', { zone: 'us-east-1' });
|
|
|
143
179
|
## Service Filtering
|
|
144
180
|
|
|
145
181
|
```typescript
|
|
182
|
+
import { ServiceStatus } from '@hazeljs/discovery';
|
|
183
|
+
|
|
146
184
|
const instances = await client.getInstances('user-service', {
|
|
147
185
|
zone: 'us-east-1',
|
|
148
186
|
status: ServiceStatus.UP,
|
|
@@ -151,17 +189,35 @@ const instances = await client.getInstances('user-service', {
|
|
|
151
189
|
});
|
|
152
190
|
```
|
|
153
191
|
|
|
192
|
+
The `applyServiceFilter` utility is also exported for use in custom backends or application code:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
import { applyServiceFilter } from '@hazeljs/discovery';
|
|
196
|
+
|
|
197
|
+
const filtered = applyServiceFilter(instances, { zone: 'us-east-1' });
|
|
198
|
+
```
|
|
199
|
+
|
|
154
200
|
## Registry Backends
|
|
155
201
|
|
|
156
202
|
### Memory (Development)
|
|
203
|
+
|
|
204
|
+
The default backend. Stores everything in-process memory -- suitable for development and testing.
|
|
205
|
+
|
|
157
206
|
```typescript
|
|
158
207
|
import { MemoryRegistryBackend } from '@hazeljs/discovery';
|
|
159
208
|
|
|
160
|
-
const backend = new MemoryRegistryBackend();
|
|
209
|
+
const backend = new MemoryRegistryBackend(90000); // optional expiration in ms
|
|
161
210
|
const registry = new ServiceRegistry(config, backend);
|
|
162
211
|
```
|
|
163
212
|
|
|
164
213
|
### Redis (Production)
|
|
214
|
+
|
|
215
|
+
Distributed registry using Redis with TTL-based expiration. Uses `SCAN` (not `KEYS`) for production safety and `MGET` for efficient batch lookups. Includes connection error handling with automatic reconnection support.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
npm install ioredis
|
|
219
|
+
```
|
|
220
|
+
|
|
165
221
|
```typescript
|
|
166
222
|
import Redis from 'ioredis';
|
|
167
223
|
import { RedisRegistryBackend } from '@hazeljs/discovery';
|
|
@@ -173,14 +229,24 @@ const redis = new Redis({
|
|
|
173
229
|
});
|
|
174
230
|
|
|
175
231
|
const backend = new RedisRegistryBackend(redis, {
|
|
176
|
-
keyPrefix: 'myapp:discovery:',
|
|
177
|
-
ttl: 90, // seconds
|
|
232
|
+
keyPrefix: 'myapp:discovery:', // default: 'hazeljs:discovery:'
|
|
233
|
+
ttl: 90, // seconds, default: 90
|
|
178
234
|
});
|
|
179
235
|
|
|
180
236
|
const registry = new ServiceRegistry(config, backend);
|
|
237
|
+
|
|
238
|
+
// On shutdown
|
|
239
|
+
await backend.close();
|
|
181
240
|
```
|
|
182
241
|
|
|
183
242
|
### Consul
|
|
243
|
+
|
|
244
|
+
Integrates with HashiCorp Consul using TTL-based health checks.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npm install consul
|
|
248
|
+
```
|
|
249
|
+
|
|
184
250
|
```typescript
|
|
185
251
|
import Consul from 'consul';
|
|
186
252
|
import { ConsulRegistryBackend } from '@hazeljs/discovery';
|
|
@@ -191,14 +257,24 @@ const consul = new Consul({
|
|
|
191
257
|
});
|
|
192
258
|
|
|
193
259
|
const backend = new ConsulRegistryBackend(consul, {
|
|
194
|
-
ttl: '30s',
|
|
260
|
+
ttl: '30s', // TTL check interval (supports "30s", "5m", "1h")
|
|
195
261
|
datacenter: 'dc1',
|
|
196
262
|
});
|
|
197
263
|
|
|
198
264
|
const registry = new ServiceRegistry(config, backend);
|
|
265
|
+
|
|
266
|
+
// On shutdown
|
|
267
|
+
await backend.close();
|
|
199
268
|
```
|
|
200
269
|
|
|
201
270
|
### Kubernetes
|
|
271
|
+
|
|
272
|
+
Read-only discovery backend that integrates with Kubernetes Endpoints API. Registration, deregistration, heartbeat, and status updates are no-ops since Kubernetes manages these through its own primitives (Services, Endpoints, probes).
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
npm install @kubernetes/client-node
|
|
276
|
+
```
|
|
277
|
+
|
|
202
278
|
```typescript
|
|
203
279
|
import { KubeConfig } from '@kubernetes/client-node';
|
|
204
280
|
import { KubernetesRegistryBackend } from '@hazeljs/discovery';
|
|
@@ -211,11 +287,66 @@ const backend = new KubernetesRegistryBackend(kubeConfig, {
|
|
|
211
287
|
labelSelector: 'app.kubernetes.io/managed-by=hazeljs',
|
|
212
288
|
});
|
|
213
289
|
|
|
214
|
-
// In Kubernetes, service registration is handled by the platform
|
|
215
290
|
// Use the backend for service discovery only
|
|
216
291
|
const client = new DiscoveryClient({}, backend);
|
|
217
292
|
```
|
|
218
293
|
|
|
294
|
+
## Smart Retry Logic
|
|
295
|
+
|
|
296
|
+
`ServiceClient` only retries on transient errors. Client errors (4xx) are thrown immediately without wasting retries:
|
|
297
|
+
|
|
298
|
+
| Error Type | Retried? |
|
|
299
|
+
|---|---|
|
|
300
|
+
| Network errors (ECONNREFUSED, timeout) | Yes |
|
|
301
|
+
| 502 Bad Gateway | Yes |
|
|
302
|
+
| 503 Service Unavailable | Yes |
|
|
303
|
+
| 504 Gateway Timeout | Yes |
|
|
304
|
+
| 408 Request Timeout | Yes |
|
|
305
|
+
| 429 Too Many Requests | Yes |
|
|
306
|
+
| 400 Bad Request | No |
|
|
307
|
+
| 401 Unauthorized | No |
|
|
308
|
+
| 403 Forbidden | No |
|
|
309
|
+
| 404 Not Found | No |
|
|
310
|
+
| Other 4xx | No |
|
|
311
|
+
|
|
312
|
+
## Custom Logging
|
|
313
|
+
|
|
314
|
+
By default, the package logs to the console with a `[discovery]` prefix. You can plug in your own logger (e.g., Winston, Pino, Bunyan):
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import { DiscoveryLogger } from '@hazeljs/discovery';
|
|
318
|
+
|
|
319
|
+
DiscoveryLogger.setLogger({
|
|
320
|
+
debug: (msg, ...args) => myLogger.debug(msg, ...args),
|
|
321
|
+
info: (msg, ...args) => myLogger.info(msg, ...args),
|
|
322
|
+
warn: (msg, ...args) => myLogger.warn(msg, ...args),
|
|
323
|
+
error: (msg, ...args) => myLogger.error(msg, ...args),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Reset to default console logger
|
|
327
|
+
DiscoveryLogger.resetLogger();
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Config Validation
|
|
331
|
+
|
|
332
|
+
All configuration objects are validated at construction time. Invalid configs throw a `ConfigValidationError` with a descriptive message:
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import { ServiceRegistry, ConfigValidationError } from '@hazeljs/discovery';
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const registry = new ServiceRegistry({
|
|
339
|
+
name: '', // invalid: empty string
|
|
340
|
+
port: -1, // invalid: negative port
|
|
341
|
+
});
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error instanceof ConfigValidationError) {
|
|
344
|
+
console.error(error.message);
|
|
345
|
+
// => 'ServiceRegistryConfig: "name" is required and must be a non-empty string'
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
219
350
|
## API Reference
|
|
220
351
|
|
|
221
352
|
### ServiceRegistry
|
|
@@ -239,6 +370,8 @@ class DiscoveryClient {
|
|
|
239
370
|
getInstance(serviceName: string, strategy?: string, filter?: ServiceFilter): Promise<ServiceInstance | null>;
|
|
240
371
|
getAllServices(): Promise<string[]>;
|
|
241
372
|
clearCache(serviceName?: string): void;
|
|
373
|
+
getLoadBalancerFactory(): LoadBalancerFactory;
|
|
374
|
+
close(): void;
|
|
242
375
|
}
|
|
243
376
|
```
|
|
244
377
|
|
|
@@ -248,10 +381,41 @@ class DiscoveryClient {
|
|
|
248
381
|
class ServiceClient {
|
|
249
382
|
constructor(discoveryClient: DiscoveryClient, config: ServiceClientConfig);
|
|
250
383
|
get<T>(path: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
251
|
-
post<T>(path: string, data?:
|
|
252
|
-
put<T>(path: string, data?:
|
|
384
|
+
post<T>(path: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
385
|
+
put<T>(path: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
253
386
|
delete<T>(path: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
254
|
-
patch<T>(path: string, data?:
|
|
387
|
+
patch<T>(path: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Configuration Types
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
interface ServiceRegistryConfig {
|
|
395
|
+
name: string;
|
|
396
|
+
port: number;
|
|
397
|
+
host?: string;
|
|
398
|
+
protocol?: 'http' | 'https' | 'grpc';
|
|
399
|
+
healthCheckPath?: string; // default: '/health'
|
|
400
|
+
healthCheckInterval?: number; // default: 30000 (ms)
|
|
401
|
+
metadata?: Record<string, unknown>;
|
|
402
|
+
zone?: string;
|
|
403
|
+
tags?: string[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface DiscoveryClientConfig {
|
|
407
|
+
cacheEnabled?: boolean;
|
|
408
|
+
cacheTTL?: number; // default: 30000 (ms)
|
|
409
|
+
refreshInterval?: number; // auto-refresh cache interval (ms)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
interface ServiceClientConfig {
|
|
413
|
+
serviceName: string;
|
|
414
|
+
loadBalancingStrategy?: string; // default: 'round-robin'
|
|
415
|
+
filter?: ServiceFilter;
|
|
416
|
+
timeout?: number; // default: 5000 (ms)
|
|
417
|
+
retries?: number; // default: 3
|
|
418
|
+
retryDelay?: number; // default: 1000 (ms)
|
|
255
419
|
}
|
|
256
420
|
```
|
|
257
421
|
|
|
@@ -265,13 +429,15 @@ See the [examples](./examples) directory for complete working examples.
|
|
|
265
429
|
npm test
|
|
266
430
|
```
|
|
267
431
|
|
|
432
|
+
The package includes 145+ unit tests across 9 test suites with 85%+ code coverage.
|
|
433
|
+
|
|
268
434
|
## Contributing
|
|
269
435
|
|
|
270
436
|
Contributions are welcome! Please read our [Contributing Guide](../../CONTRIBUTING.md) for details.
|
|
271
437
|
|
|
272
438
|
## License
|
|
273
439
|
|
|
274
|
-
MIT
|
|
440
|
+
MIT © [HazelJS](https://hazeljs.com)
|
|
275
441
|
|
|
276
442
|
## Links
|
|
277
443
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consul-backend.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/consul-backend.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Consul Backend Tests
|
|
4
|
+
* Uses mocked Consul client
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const consul_backend_1 = require("../backends/consul-backend");
|
|
8
|
+
const types_1 = require("../types");
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
// Suppress console logs during tests
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
logger_1.DiscoveryLogger.setLogger({
|
|
13
|
+
debug: jest.fn(),
|
|
14
|
+
info: jest.fn(),
|
|
15
|
+
warn: jest.fn(),
|
|
16
|
+
error: jest.fn(),
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
logger_1.DiscoveryLogger.resetLogger();
|
|
21
|
+
});
|
|
22
|
+
function createMockConsul() {
|
|
23
|
+
return {
|
|
24
|
+
agent: {
|
|
25
|
+
service: {
|
|
26
|
+
register: jest.fn().mockResolvedValue(undefined),
|
|
27
|
+
deregister: jest.fn().mockResolvedValue(undefined),
|
|
28
|
+
list: jest.fn().mockResolvedValue({}),
|
|
29
|
+
},
|
|
30
|
+
check: {
|
|
31
|
+
pass: jest.fn().mockResolvedValue(undefined),
|
|
32
|
+
fail: jest.fn().mockResolvedValue(undefined),
|
|
33
|
+
warn: jest.fn().mockResolvedValue(undefined),
|
|
34
|
+
list: jest.fn().mockResolvedValue({}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
health: {
|
|
38
|
+
service: jest.fn().mockResolvedValue([]),
|
|
39
|
+
},
|
|
40
|
+
catalog: {
|
|
41
|
+
service: {
|
|
42
|
+
list: jest.fn().mockResolvedValue({}),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function createInstance(id, name = 'test-service', overrides = {}) {
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
name,
|
|
51
|
+
host: 'localhost',
|
|
52
|
+
port: 3000,
|
|
53
|
+
status: types_1.ServiceStatus.UP,
|
|
54
|
+
lastHeartbeat: new Date('2025-01-01T00:00:00Z'),
|
|
55
|
+
registeredAt: new Date('2025-01-01T00:00:00Z'),
|
|
56
|
+
tags: ['web'],
|
|
57
|
+
metadata: { version: '1.0' },
|
|
58
|
+
zone: 'us-east-1',
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
describe('ConsulRegistryBackend', () => {
|
|
63
|
+
let consul;
|
|
64
|
+
let backend;
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
jest.useFakeTimers();
|
|
67
|
+
consul = createMockConsul();
|
|
68
|
+
backend = new consul_backend_1.ConsulRegistryBackend(consul);
|
|
69
|
+
});
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
await backend.close();
|
|
72
|
+
jest.useRealTimers();
|
|
73
|
+
});
|
|
74
|
+
describe('constructor', () => {
|
|
75
|
+
it('should use default TTL of 30s', () => {
|
|
76
|
+
expect(backend).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
it('should accept custom config', () => {
|
|
79
|
+
const b = new consul_backend_1.ConsulRegistryBackend(consul, { ttl: '60s' });
|
|
80
|
+
expect(b).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
it('should throw on invalid TTL format', () => {
|
|
83
|
+
expect(() => {
|
|
84
|
+
new consul_backend_1.ConsulRegistryBackend(consul, { ttl: 'invalid' });
|
|
85
|
+
}).toThrow();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('register', () => {
|
|
89
|
+
it('should call consul agent service register', async () => {
|
|
90
|
+
const instance = createInstance('svc-1', 'my-service');
|
|
91
|
+
await backend.register(instance);
|
|
92
|
+
expect(consul.agent.service.register).toHaveBeenCalledWith(expect.objectContaining({
|
|
93
|
+
id: 'svc-1',
|
|
94
|
+
name: 'my-service',
|
|
95
|
+
address: 'localhost',
|
|
96
|
+
port: 3000,
|
|
97
|
+
}));
|
|
98
|
+
});
|
|
99
|
+
it('should start TTL check interval', async () => {
|
|
100
|
+
const instance = createInstance('svc-1');
|
|
101
|
+
await backend.register(instance);
|
|
102
|
+
// Advance timers past the TTL check interval (2/3 of 30s = 20s)
|
|
103
|
+
jest.advanceTimersByTime(21000);
|
|
104
|
+
expect(consul.agent.check.pass).toHaveBeenCalledWith('service:svc-1');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('deregister', () => {
|
|
108
|
+
it('should call consul agent service deregister', async () => {
|
|
109
|
+
const instance = createInstance('svc-1');
|
|
110
|
+
await backend.register(instance);
|
|
111
|
+
await backend.deregister('svc-1');
|
|
112
|
+
expect(consul.agent.service.deregister).toHaveBeenCalledWith('svc-1');
|
|
113
|
+
});
|
|
114
|
+
it('should stop TTL check interval', async () => {
|
|
115
|
+
const instance = createInstance('svc-1');
|
|
116
|
+
await backend.register(instance);
|
|
117
|
+
await backend.deregister('svc-1');
|
|
118
|
+
jest.clearAllMocks();
|
|
119
|
+
jest.advanceTimersByTime(30000);
|
|
120
|
+
// TTL check should NOT have been called after deregister
|
|
121
|
+
expect(consul.agent.check.pass).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('heartbeat', () => {
|
|
125
|
+
it('should pass the TTL check', async () => {
|
|
126
|
+
await backend.heartbeat('svc-1');
|
|
127
|
+
expect(consul.agent.check.pass).toHaveBeenCalledWith('service:svc-1');
|
|
128
|
+
});
|
|
129
|
+
it('should not throw when consul fails', async () => {
|
|
130
|
+
consul.agent.check.pass.mockRejectedValueOnce(new Error('Consul error'));
|
|
131
|
+
await expect(backend.heartbeat('svc-1')).resolves.not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('getInstances', () => {
|
|
135
|
+
it('should return instances from consul health service', async () => {
|
|
136
|
+
consul.health.service.mockResolvedValueOnce([
|
|
137
|
+
{
|
|
138
|
+
Service: {
|
|
139
|
+
ID: 'svc-1',
|
|
140
|
+
Service: 'my-service',
|
|
141
|
+
Address: '10.0.0.1',
|
|
142
|
+
Port: 8080,
|
|
143
|
+
Meta: { zone: 'us-east-1', registeredAt: '2025-01-01T00:00:00Z' },
|
|
144
|
+
Tags: ['web'],
|
|
145
|
+
},
|
|
146
|
+
Checks: [{ Status: 'passing' }],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
Service: {
|
|
150
|
+
ID: 'svc-2',
|
|
151
|
+
Service: 'my-service',
|
|
152
|
+
Address: '10.0.0.2',
|
|
153
|
+
Port: 8080,
|
|
154
|
+
Meta: { zone: 'us-west-1' },
|
|
155
|
+
Tags: ['api'],
|
|
156
|
+
},
|
|
157
|
+
Checks: [{ Status: 'passing' }],
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
const instances = await backend.getInstances('my-service');
|
|
161
|
+
expect(instances).toHaveLength(2);
|
|
162
|
+
expect(instances[0].id).toBe('svc-1');
|
|
163
|
+
expect(instances[0].host).toBe('10.0.0.1');
|
|
164
|
+
});
|
|
165
|
+
it('should set status based on check results', async () => {
|
|
166
|
+
consul.health.service.mockResolvedValueOnce([
|
|
167
|
+
{
|
|
168
|
+
Service: {
|
|
169
|
+
ID: 'svc-1',
|
|
170
|
+
Service: 'my-service',
|
|
171
|
+
Address: '10.0.0.1',
|
|
172
|
+
Port: 8080,
|
|
173
|
+
},
|
|
174
|
+
Checks: [{ Status: 'critical' }],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
Service: {
|
|
178
|
+
ID: 'svc-2',
|
|
179
|
+
Service: 'my-service',
|
|
180
|
+
Address: '10.0.0.2',
|
|
181
|
+
Port: 8080,
|
|
182
|
+
},
|
|
183
|
+
Checks: [{ Status: 'warning' }],
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
const instances = await backend.getInstances('my-service');
|
|
187
|
+
expect(instances[0].status).toBe(types_1.ServiceStatus.DOWN);
|
|
188
|
+
expect(instances[1].status).toBe(types_1.ServiceStatus.STARTING);
|
|
189
|
+
});
|
|
190
|
+
it('should filter instances', async () => {
|
|
191
|
+
consul.health.service.mockResolvedValueOnce([
|
|
192
|
+
{
|
|
193
|
+
Service: {
|
|
194
|
+
ID: 'svc-1',
|
|
195
|
+
Service: 'my-service',
|
|
196
|
+
Address: '10.0.0.1',
|
|
197
|
+
Port: 8080,
|
|
198
|
+
Meta: { zone: 'us-east-1' },
|
|
199
|
+
},
|
|
200
|
+
Checks: [],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
Service: {
|
|
204
|
+
ID: 'svc-2',
|
|
205
|
+
Service: 'my-service',
|
|
206
|
+
Address: '10.0.0.2',
|
|
207
|
+
Port: 8080,
|
|
208
|
+
Meta: { zone: 'us-west-1' },
|
|
209
|
+
},
|
|
210
|
+
Checks: [],
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
const instances = await backend.getInstances('my-service', { zone: 'us-east-1' });
|
|
214
|
+
expect(instances).toHaveLength(1);
|
|
215
|
+
expect(instances[0].id).toBe('svc-1');
|
|
216
|
+
});
|
|
217
|
+
it('should return empty array on error', async () => {
|
|
218
|
+
consul.health.service.mockRejectedValueOnce(new Error('Consul error'));
|
|
219
|
+
const instances = await backend.getInstances('my-service');
|
|
220
|
+
expect(instances).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
describe('getInstance', () => {
|
|
224
|
+
it('should return a specific instance', async () => {
|
|
225
|
+
consul.agent.service.list.mockResolvedValueOnce({
|
|
226
|
+
'svc-1': {
|
|
227
|
+
ID: 'svc-1',
|
|
228
|
+
Service: 'my-service',
|
|
229
|
+
Address: '10.0.0.1',
|
|
230
|
+
Port: 8080,
|
|
231
|
+
Meta: { zone: 'us-east-1' },
|
|
232
|
+
Tags: ['web'],
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
consul.agent.check.list.mockResolvedValueOnce({
|
|
236
|
+
'service:svc-1': { Status: 'passing' },
|
|
237
|
+
});
|
|
238
|
+
const instance = await backend.getInstance('svc-1');
|
|
239
|
+
expect(instance).toBeDefined();
|
|
240
|
+
expect(instance.id).toBe('svc-1');
|
|
241
|
+
expect(instance.status).toBe(types_1.ServiceStatus.UP);
|
|
242
|
+
});
|
|
243
|
+
it('should return null for non-existent instance', async () => {
|
|
244
|
+
consul.agent.service.list.mockResolvedValueOnce({});
|
|
245
|
+
const instance = await backend.getInstance('non-existent');
|
|
246
|
+
expect(instance).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
it('should return null on error', async () => {
|
|
249
|
+
consul.agent.service.list.mockRejectedValueOnce(new Error('fail'));
|
|
250
|
+
const instance = await backend.getInstance('svc-1');
|
|
251
|
+
expect(instance).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe('getAllServices', () => {
|
|
255
|
+
it('should return service names from catalog', async () => {
|
|
256
|
+
consul.catalog.service.list.mockResolvedValueOnce({
|
|
257
|
+
'service-a': [],
|
|
258
|
+
'service-b': [],
|
|
259
|
+
consul: [],
|
|
260
|
+
});
|
|
261
|
+
const services = await backend.getAllServices();
|
|
262
|
+
expect(services).toEqual(['service-a', 'service-b', 'consul']);
|
|
263
|
+
});
|
|
264
|
+
it('should return empty array on error', async () => {
|
|
265
|
+
consul.catalog.service.list.mockRejectedValueOnce(new Error('fail'));
|
|
266
|
+
const services = await backend.getAllServices();
|
|
267
|
+
expect(services).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
describe('updateStatus', () => {
|
|
271
|
+
it('should pass check for UP status', async () => {
|
|
272
|
+
await backend.updateStatus('svc-1', types_1.ServiceStatus.UP);
|
|
273
|
+
expect(consul.agent.check.pass).toHaveBeenCalledWith('service:svc-1');
|
|
274
|
+
});
|
|
275
|
+
it('should fail check for DOWN status', async () => {
|
|
276
|
+
await backend.updateStatus('svc-1', types_1.ServiceStatus.DOWN);
|
|
277
|
+
expect(consul.agent.check.fail).toHaveBeenCalledWith('service:svc-1');
|
|
278
|
+
});
|
|
279
|
+
it('should warn check for STARTING status', async () => {
|
|
280
|
+
await backend.updateStatus('svc-1', types_1.ServiceStatus.STARTING);
|
|
281
|
+
expect(consul.agent.check.warn).toHaveBeenCalledWith('service:svc-1');
|
|
282
|
+
});
|
|
283
|
+
it('should not throw on error', async () => {
|
|
284
|
+
consul.agent.check.pass.mockRejectedValueOnce(new Error('fail'));
|
|
285
|
+
await expect(backend.updateStatus('svc-1', types_1.ServiceStatus.UP)).resolves.not.toThrow();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe('close', () => {
|
|
289
|
+
it('should stop all TTL check intervals', async () => {
|
|
290
|
+
const i1 = createInstance('svc-1');
|
|
291
|
+
const i2 = createInstance('svc-2');
|
|
292
|
+
await backend.register(i1);
|
|
293
|
+
await backend.register(i2);
|
|
294
|
+
await backend.close();
|
|
295
|
+
jest.clearAllMocks();
|
|
296
|
+
jest.advanceTimersByTime(60000);
|
|
297
|
+
expect(consul.agent.check.pass).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kubernetes-backend.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/kubernetes-backend.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|