# @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 ```bash pnpm add @lightweight-sdk/service-discovery ``` ## Usage ### Un service qui s'enregistre ```ts 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 ```ts 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`) — à monter toi-même dans ton serveur existant : **Express** (`req`/`res` Express *sont* des `IncomingMessage`/`ServerResponse`) : ```ts 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) : ```ts 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** : ```ts 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/users` est matché contre l'endpoint `GET /users` déclaré par le service `mon-service`, puis relayé vers `${baseUrl}/users`. - **Secret partagé** : toute requête d'enregistrement/désinscription doit présenter `Authorization: Bearer `. 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é `discovery` si 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 `baseUrl` arbitraire 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`.