Agregateur d'API reverse-proxy avec enregistrement et decouverte de
services au runtime, deux points d'entree separes partageant un meme
protocole :
- ./client (DiscoveryClient) : enregistre un service et maintient un
heartbeat periodique (qui sert aussi de mecanisme de retry - un echec
est journalise et laisse au tick suivant, pas de logique separee).
stop() est best-effort et ne rejette jamais.
- ./aggregator (ServiceAggregator) : handlers Node natifs a monter dans
Express/Fastify/http brut (exemples des trois dans le README), zero
dependance framework. Routage par prefixe de nom de service, TTL avec
balayage periodique, secret partage compare en temps constant, proxy
HTTP fait main (node:http/https, streaming, filtrage hop-by-hop,
distinction 502/504).
Limites documentees dans le README plutot que corrigees : pas de
matching :param (v1, match exact), pas de secret par service, risque
SSRF-like assume vu le threat model homelab.
Resolution des sous-chemins via des stubs package.json (client/,
aggregator/) puisque le repo cible moduleResolution: Node10, pas de
champ exports moderne - pattern documente dans spec/architecture.md
pour de futurs packages server/ multi-points d'entree.
Bug trouve et corrige en cours de route : req.on('close') se declenche
des que le corps de la requete entrante est lu (immediat pour un GET
sans body), pas a la deconnexion reelle du client - detruisait ma
propre requete sortante juste apres l'avoir envoyee. Le bon signal est
res.on('close').
Correction connexe : create-package.mjs inserait la nouvelle ligne en
fin de fichier de spec/packages.md au lieu de l'inserer dans le
tableau - corrige pour les deux packages precedents au passage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| aggregator | ||
| client | ||
| src | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
@lightweight-sdk/service-discovery
Agrégateur d'API reverse-proxy avec enregistrement et découverte de services au runtime. Package server : deux points d'entrée séparés (./client et ./aggregator), zéro dépendance framework (node:http/node:https/fetch uniquement).
Installation
pnpm add @lightweight-sdk/service-discovery
Usage
Un service qui s'enregistre
import { DiscoveryClient } from '@lightweight-sdk/service-discovery/client';
const client = new DiscoveryClient({
aggregatorUrl: 'http://aggregator.internal:8080',
serviceName: 'my-service',
baseUrl: 'http://my-service.internal:3000',
endpoints: [{ method: 'GET', path: '/users' }],
sharedSecret: process.env.DISCOVERY_SECRET!,
});
await client.start(); // enregistrement initial + heartbeat périodique
process.on('SIGTERM', () => {
void client.stop(); // désinscription best-effort, arrête le heartbeat
});
L'agrégateur
import { ServiceAggregator } from '@lightweight-sdk/service-discovery/aggregator';
const aggregator = new ServiceAggregator({ sharedSecret: process.env.DISCOVERY_SECRET! });
aggregator.start(); // démarre le balayage d'expiration (TTL)
Le cœur s'expose comme des handlers Node natifs ((req: IncomingMessage, res: ServerResponse) => void | Promise<void>) — à monter toi-même dans ton serveur existant :
Express (req/res Express sont des IncomingMessage/ServerResponse) :
app.post('/discovery/register', aggregator.registrationHandler);
app.post('/discovery/deregister', aggregator.deregistrationHandler);
app.all('*', aggregator.proxyHandler); // monté en dernier, après tes propres routes
⚠️ '*' seul est spécifique à Express 4. Express 5 a cassé ce wildcard non-nommé (path-to-regexp v8) — il faut '/*splat'.
Fastify (reply.hijack() indique à Fastify de ne plus toucher reply après cet appel) :
fastify.post('/discovery/register', async (request, reply) => {
reply.hijack();
await aggregator.registrationHandler(request.raw, reply.raw).catch((e) => request.log.error(e));
});
fastify.all('/*', async (request, reply) => {
reply.hijack();
await aggregator.proxyHandler(request.raw, reply.raw).catch((e) => request.log.error(e));
});
node:http brut :
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/discovery/register') return void aggregator.registrationHandler(req, res);
if (req.method === 'POST' && req.url === '/discovery/deregister') return void aggregator.deregistrationHandler(req, res);
void aggregator.proxyHandler(req, res);
});
Une requête GET http://aggregator.internal:8080/my-service/users est alors relayée vers http://my-service.internal:3000/users.
Concepts
- Enregistrement = heartbeat :
DiscoveryClient.start()envoie un enregistrement initial puis répète le même appel à intervalle régulier (heartbeatIntervalMs). C'est aussi le mécanisme de retry : un échec (agrégateur pas encore démarré, panne transitoire) est juste loggé, le prochain tick réessaie — pas de logique de retry séparée. - TTL côté agrégateur : chaque enregistrement expire après
ttlMs(par défaut 3x l'intervalle de heartbeat par défaut, pour tolérer 1-2 heartbeats manqués) sans renouvellement. Un balayage périodique (sweepIntervalMs) évince les entrées expirées. Pas de persistance : si l'agrégateur redémarre, chaque service se réenregistre au heartbeat suivant. - Routage par préfixe de nom de service :
GET /mon-service/usersest matché contre l'endpointGET /usersdéclaré par le servicemon-service, puis relayé vers${baseUrl}/users. - Secret partagé : toute requête d'enregistrement/désinscription doit présenter
Authorization: Bearer <secret>. Comparaison en temps constant (crypto.timingSafeEqual).
Options
DiscoveryClientOptions
| Option | Défaut | Description |
|---|---|---|
heartbeatIntervalMs |
15000 |
Intervalle entre deux heartbeats |
requestTimeoutMs |
5000 |
Timeout de chaque requête d'enregistrement/heartbeat/désinscription |
registerPath |
/discovery/register |
Chemin d'enregistrement sur l'agrégateur |
deregisterPath |
/discovery/deregister |
Chemin de désinscription sur l'agrégateur |
ServiceAggregatorOptions
| Option | Défaut | Description |
|---|---|---|
ttlMs |
45000 |
Durée de validité d'un enregistrement sans heartbeat |
sweepIntervalMs |
10000 |
Fréquence du balayage d'éviction |
proxyTimeoutMs |
10000 |
Timeout d'inactivité socket (pas une durée totale) vers le backend |
maxRegistrationBodyBytes |
65536 |
Taille max acceptée pour un payload d'enregistrement/désinscription |
Limites connues
- Pas de matching
:param— le routage fait un match exact du chemin (/users≠/users/:id). Une vraie API REST a presque toujours ce genre de routes ; l'ajout d'un matching segment par segment façon Express est une extension future volontairement différée. - Pas de secret par service — un seul secret partagé pour toute la registry.
- Pas de persistance — décision assumée, voir "Concepts" ci-dessus.
- Collision de nom — un service enregistré sous un nom identique au segment de chemin où tu montes les routes de contrôle (ex. un service nommé
discoverysi tu montes sous/discovery/*) verrait ses propres endpoints inaccessibles, la route de contrôle étant toujours prioritaire. À éviter par convention de nommage. - Risque à connaître (pas corrigé) : dans le threat model visé (homelab, services de confiance), un service compromis pourrait déclarer un
baseUrlarbitraire et transformer l'agrégateur en proxy ouvert vers ce point (SSRF-like). Aucun enforcement n'est prévu — à garder en tête si le threat model change.
API
@lightweight-sdk/service-discovery(racine) — protocole partagé uniquement :RegistrationPayload,ServiceEndpoint,HttpMethod,RegistrationAck,DeregistrationPayload.@lightweight-sdk/service-discovery/client—DiscoveryClient,DiscoveryClientOptions.@lightweight-sdk/service-discovery/aggregator—ServiceAggregator(registrationHandler,deregistrationHandler,proxyHandler,start(),stop(),getRegisteredServiceNames()),ServiceAggregatorOptions.