Loading...
The @hazeljs/discovery package provides service discovery and registry capabilities for HazelJS microservices, inspired by Netflix Eureka and HashiCorp Consul. It enables microservices to register themselves, discover other services, and communicate using client-side load balancing — all backed by pluggable storage backends.
Building microservices means services need to find and talk to each other. Hard-coding URLs breaks when services scale, move, or fail. The @hazeljs/discovery package solves this by providing:
The package follows a layered architecture with pluggable backends and load balancing strategies:
@ServiceRegistry and @InjectServiceClient for HazelJS integrationnpm install @hazeljs/discovery
Install the optional backend you need:
# Redis backend (production)
npm install ioredis
# Consul backend
npm install consul
# Kubernetes backend
npm install @kubernetes/client-node
Every microservice registers itself so other services can discover it:
import { ServiceRegistry, MemoryRegistryBackend } from '@hazeljs/discovery';
const registry = new ServiceRegistry({
name: 'user-service',
port: 3000,
host: 'localhost',
protocol: 'http',
healthCheckPath: '/health',
healthCheckInterval: 30000,
metadata: { version: '1.0.0' },
zone: 'us-east-1',
tags: ['api', 'users'],
});
// Register on startup
await registry.register();
// Deregister on shutdown
process.on('SIGTERM', async () => {
await registry.deregister();
process.exit(0);
});
Other services use the DiscoveryClient to find registered instances:
import { DiscoveryClient } from '@hazeljs/discovery';
const client = new DiscoveryClient({
cacheEnabled: true,
cacheTTL: 30000,
refreshInterval: 15000,
});
// Get all instances of a service
const instances = await client.getInstances('user-service');
// Get one instance using load balancing
const instance = await client.getInstance('user-service', 'round-robin');
// Clean up on shutdown
client.close();
The ServiceClient combines discovery + load balancing + HTTP into a single API:
import { ServiceClient, DiscoveryClient } from '@hazeljs/discovery';
const discoveryClient = new DiscoveryClient({ cacheEnabled: true });
const userService = new ServiceClient(discoveryClient, {
serviceName: 'user-service',
loadBalancingStrategy: 'round-robin',
timeout: 5000,
retries: 3,
retryDelay: 1000,
});
// Automatic service discovery + load balancing + smart retries
const user = await userService.get('/users/123');
const created = await userService.post('/users', { name: 'John' });
const updated = await userService.put('/users/123', { name: 'Jane' });
await userService.delete('/users/123');
Use decorators for clean integration with HazelJS modules:
import { ServiceRegistryDecorator, InjectServiceClient } from '@hazeljs/discovery';
import { Injectable } from '@hazeljs/core';
@ServiceRegistryDecorator({
name: 'order-service',
port: 3001,
healthCheckPath: '/health',
})
export class AppModule {}
@Injectable()
export class OrderService {
constructor(
@InjectServiceClient('user-service')
private userClient: ServiceClient
) {}
async createOrder(userId: string) {
const user = await this.userClient.get(`/users/${userId}`);
// ... create order logic
}
}
The package ships with 6 load balancing strategies. All strategies automatically filter out unhealthy instances (status !== UP).
Distributes requests evenly across instances in order:
const instance = await client.getInstance('service-name', 'round-robin');
Picks a random healthy instance:
const instance = await client.getInstance('service-name', 'random');
Routes to the instance with the fewest active connections. When used with ServiceClient, connection counts are tracked automatically:
const serviceClient = new ServiceClient(discoveryClient, {
serviceName: 'user-service',
loadBalancingStrategy: 'least-connections',
});
// Connection tracking happens automatically per request
const user = await serviceClient.get('/users/123');
Routes more traffic to instances with higher weight metadata:
const registry = new ServiceRegistry({
name: 'api-service',
port: 3000,
metadata: { weight: 5 }, // Higher weight = more traffic
});
Consistently routes the same client IP to the same instance:
const instance = await client.getInstance('service-name', 'ip-hash');
Prefers instances in the same availability zone, falls back to random:
const factory = client.getLoadBalancerFactory();
const strategy = factory.create('zone-aware', { zone: 'us-east-1' });
Filter instances by zone, status, tags, or metadata:
import { ServiceStatus } from '@hazeljs/discovery';
const instances = await client.getInstances('user-service', {
zone: 'us-east-1',
status: ServiceStatus.UP,
tags: ['api', 'production'],
metadata: { version: '2.0.0' },
});
The applyServiceFilter utility is also exported for use in custom code:
import { applyServiceFilter } from '@hazeljs/discovery';
const filtered = applyServiceFilter(allInstances, { zone: 'us-east-1' });
The default backend. Stores instances in-process memory. Suitable for development and testing:
import { MemoryRegistryBackend, ServiceRegistry } from '@hazeljs/discovery';
const backend = new MemoryRegistryBackend(90000); // expiration in ms
const registry = new ServiceRegistry(config, backend);
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 state tracking:
import Redis from 'ioredis';
import { RedisRegistryBackend, ServiceRegistry } from '@hazeljs/discovery';
const redis = new Redis({
host: 'localhost',
port: 6379,
password: 'your-password',
});
const backend = new RedisRegistryBackend(redis, {
keyPrefix: 'myapp:discovery:', // default: 'hazeljs:discovery:'
ttl: 90, // seconds, default: 90
});
const registry = new ServiceRegistry(config, backend);
// On shutdown
await registry.deregister();
await backend.close();
Production features:
SCAN instead of KEYS for safe key enumerationMGET for batch instance lookups (no N+1 queries)Integrates with HashiCorp Consul using TTL-based health checks:
import Consul from 'consul';
import { ConsulRegistryBackend, ServiceRegistry } from '@hazeljs/discovery';
const consul = new Consul({ host: 'localhost', port: 8500 });
const backend = new ConsulRegistryBackend(consul, {
ttl: '30s', // supports "30s", "5m", "1h"
datacenter: 'dc1',
});
const registry = new ServiceRegistry(config, backend);
// On shutdown
await registry.deregister();
await backend.close();
Read-only discovery that integrates with the Kubernetes Endpoints API. Registration, heartbeat, and status updates are no-ops since Kubernetes manages these through its own primitives:
import { KubeConfig } from '@kubernetes/client-node';
import { KubernetesRegistryBackend, DiscoveryClient } from '@hazeljs/discovery';
const kubeConfig = new KubeConfig();
kubeConfig.loadFromDefault();
const backend = new KubernetesRegistryBackend(kubeConfig, {
namespace: 'default',
labelSelector: 'app.kubernetes.io/managed-by=hazeljs',
});
// Use for discovery only (registration handled by K8s)
const client = new DiscoveryClient({}, backend);
const instances = await client.getInstances('my-service');
Kubernetes-specific features:
topology.kubernetes.io/zone labelsThe ServiceClient only retries on transient errors. Client errors like 400 Bad Request or 404 Not Found are thrown immediately without wasting retries:
| Error Type | Retried? |
|---|---|
| Network errors (ECONNREFUSED, timeout) | Yes |
| 502 Bad Gateway | Yes |
| 503 Service Unavailable | Yes |
| 504 Gateway Timeout | Yes |
| 408 Request Timeout | Yes |
| 429 Too Many Requests | Yes |
| 400 Bad Request | No |
| 401 Unauthorized | No |
| 404 Not Found | No |
| Other 4xx | No |
By default, the package logs to the console with a [discovery] prefix. You can plug in your own logger:
import { DiscoveryLogger } from '@hazeljs/discovery';
// Use with Winston
import winston from 'winston';
const logger = winston.createLogger({ /* ... */ });
DiscoveryLogger.setLogger({
debug: (msg, ...args) => logger.debug(msg, { args }),
info: (msg, ...args) => logger.info(msg, { args }),
warn: (msg, ...args) => logger.warn(msg, { args }),
error: (msg, ...args) => logger.error(msg, { args }),
});
// Reset to default console logger
DiscoveryLogger.resetLogger();
All configuration objects are validated at construction time. Invalid configs throw a ConfigValidationError:
import { ServiceRegistry, ConfigValidationError } from '@hazeljs/discovery';
try {
new ServiceRegistry({ name: '', port: -1 });
} catch (error) {
if (error instanceof ConfigValidationError) {
console.error(error.message);
// 'ServiceRegistryConfig: "name" is required and must be a non-empty string'
}
}
Validated fields include ports (0-65535), TTL values, protocol types, service names, retry counts, and more.
Here is a full example showing two services communicating via discovery:
User Service (registers itself):
import { ServiceRegistry, MemoryRegistryBackend } from '@hazeljs/discovery';
import express from 'express';
const app = express();
const backend = new MemoryRegistryBackend();
const registry = new ServiceRegistry({
name: 'user-service',
port: 3001,
host: 'localhost',
healthCheckPath: '/health',
}, backend);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id, name: 'John Doe' });
});
app.listen(3001, async () => {
await registry.register();
console.log('User service running on :3001');
});
Order Service (discovers and calls user service):
import { DiscoveryClient, ServiceClient } from '@hazeljs/discovery';
// Share the same backend in development, or use Redis in production
const client = new DiscoveryClient({ cacheEnabled: true }, backend);
const userService = new ServiceClient(client, {
serviceName: 'user-service',
loadBalancingStrategy: 'round-robin',
retries: 3,
});
// This automatically discovers user-service, picks an instance, and makes the HTTP call
const user = await userService.get('/users/123');
console.log(user.data); // { id: '123', name: 'John Doe' }
Production setup with Redis:
import Redis from 'ioredis';
import { RedisRegistryBackend, ServiceRegistry, DiscoveryClient, ServiceClient } from '@hazeljs/discovery';
const redis = new Redis({ host: 'redis.example.com', port: 6379 });
const backend = new RedisRegistryBackend(redis, { ttl: 90 });
// Each service registers with the shared Redis backend
const registry = new ServiceRegistry({
name: 'order-service',
port: 3002,
host: 'order-svc.internal',
healthCheckPath: '/health',
tags: ['api', 'orders'],
zone: 'us-east-1',
}, backend);
await registry.register();
// Discover other services via the same Redis backend
const discovery = new DiscoveryClient({ cacheEnabled: true, cacheTTL: 10000 }, backend);
const userClient = new ServiceClient(discovery, {
serviceName: 'user-service',
loadBalancingStrategy: 'least-connections',
});
const paymentClient = new ServiceClient(discovery, {
serviceName: 'payment-service',
loadBalancingStrategy: 'round-robin',
retries: 5,
retryDelay: 2000,
});
Use discovery to build an API gateway that routes to backend services:
import { DiscoveryClient, ServiceClient } from '@hazeljs/discovery';
import express from 'express';
const app = express();
const discovery = new DiscoveryClient({ cacheEnabled: true }, backend);
// Create clients for each downstream service
const services = {
users: new ServiceClient(discovery, { serviceName: 'user-service' }),
orders: new ServiceClient(discovery, { serviceName: 'order-service' }),
payments: new ServiceClient(discovery, { serviceName: 'payment-service' }),
};
// Route to appropriate service
app.use('/api/users/*', async (req, res) => {
const response = await services.users.get(req.path.replace('/api/users', ''));
res.json(response.data);
});
app.use('/api/orders/*', async (req, res) => {
const response = await services.orders.get(req.path.replace('/api/orders', ''));
res.json(response.data);
});
Choose the right backend: Use Memory for development, Redis for production distributed systems, Consul for existing HashiCorp infrastructure, Kubernetes for K8s-native apps.
Always deregister on shutdown: Use process.on('SIGTERM', ...) or framework lifecycle hooks to call registry.deregister() and client.close().
Enable caching: Set cacheEnabled: true with an appropriate cacheTTL. For high-throughput systems, also set refreshInterval to keep the cache warm.
Use tags and zones: Tag services by environment, version, or role. Use zones for locality-aware routing.
Set appropriate health check intervals: 30 seconds is a good default. Shorter intervals detect failures faster but add more network overhead.
Use least-connections for uneven workloads: If requests have varying processing times, least-connections distributes load more evenly than round-robin.
Plug in your production logger: Replace the default console logger with your application's logger via DiscoveryLogger.setLogger().
Handle errors gracefully: Wrap service calls in try/catch. The ServiceClient retries transient errors automatically, but you should still handle final failures.