From 509bd3a537bfd1a2bb80274b4f315a4ca30dc03b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 18 Sep 2026 19:01:33 +0200 Subject: [PATCH] Ajouter @lightweight-sdk/service-discovery (packages/server) 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 --- .changeset/add-service-discovery-package.md | 5 + packages/server/service-discovery/README.md | 114 +++++++++++ .../service-discovery/aggregator/package.json | 4 + .../service-discovery/client/package.json | 4 + .../server/service-discovery/package.json | 32 +++ .../src/aggregator/http-utils.test.ts | 59 ++++++ .../src/aggregator/http-utils.ts | 68 +++++++ .../service-discovery/src/aggregator/index.ts | 3 + .../src/aggregator/proxy-request.test.ts | 141 +++++++++++++ .../src/aggregator/proxy-request.ts | 85 ++++++++ .../src/aggregator/registered-service.ts | 11 ++ .../src/aggregator/route-matching.test.ts | 72 +++++++ .../src/aggregator/route-matching.ts | 45 +++++ .../src/aggregator/service-aggregator.test.ts | 144 ++++++++++++++ .../src/aggregator/service-aggregator.ts | 185 ++++++++++++++++++ .../aggregator/validate-registration.test.ts | 64 ++++++ .../src/aggregator/validate-registration.ts | 94 +++++++++ .../src/client/discovery-client.test.ts | 150 ++++++++++++++ .../src/client/discovery-client.ts | 151 ++++++++++++++ .../service-discovery/src/client/index.ts | 3 + .../server/service-discovery/src/index.ts | 7 + .../service-discovery/src/service-name.ts | 17 ++ .../src/test-support/ephemeral-http-server.ts | 39 ++++ .../server/service-discovery/src/types.ts | 37 ++++ .../server/service-discovery/tsconfig.json | 9 + pnpm-lock.yaml | 12 ++ scripts/create-package.mjs | 20 +- spec/architecture.md | 15 ++ spec/packages.md | 4 +- tsconfig.json | 3 + 30 files changed, 1592 insertions(+), 5 deletions(-) create mode 100644 .changeset/add-service-discovery-package.md create mode 100644 packages/server/service-discovery/README.md create mode 100644 packages/server/service-discovery/aggregator/package.json create mode 100644 packages/server/service-discovery/client/package.json create mode 100644 packages/server/service-discovery/package.json create mode 100644 packages/server/service-discovery/src/aggregator/http-utils.test.ts create mode 100644 packages/server/service-discovery/src/aggregator/http-utils.ts create mode 100644 packages/server/service-discovery/src/aggregator/index.ts create mode 100644 packages/server/service-discovery/src/aggregator/proxy-request.test.ts create mode 100644 packages/server/service-discovery/src/aggregator/proxy-request.ts create mode 100644 packages/server/service-discovery/src/aggregator/registered-service.ts create mode 100644 packages/server/service-discovery/src/aggregator/route-matching.test.ts create mode 100644 packages/server/service-discovery/src/aggregator/route-matching.ts create mode 100644 packages/server/service-discovery/src/aggregator/service-aggregator.test.ts create mode 100644 packages/server/service-discovery/src/aggregator/service-aggregator.ts create mode 100644 packages/server/service-discovery/src/aggregator/validate-registration.test.ts create mode 100644 packages/server/service-discovery/src/aggregator/validate-registration.ts create mode 100644 packages/server/service-discovery/src/client/discovery-client.test.ts create mode 100644 packages/server/service-discovery/src/client/discovery-client.ts create mode 100644 packages/server/service-discovery/src/client/index.ts create mode 100644 packages/server/service-discovery/src/index.ts create mode 100644 packages/server/service-discovery/src/service-name.ts create mode 100644 packages/server/service-discovery/src/test-support/ephemeral-http-server.ts create mode 100644 packages/server/service-discovery/src/types.ts create mode 100644 packages/server/service-discovery/tsconfig.json diff --git a/.changeset/add-service-discovery-package.md b/.changeset/add-service-discovery-package.md new file mode 100644 index 0000000..3832f72 --- /dev/null +++ b/.changeset/add-service-discovery-package.md @@ -0,0 +1,5 @@ +--- +"@lightweight-sdk/service-discovery": minor +--- + +Ajout du package `@lightweight-sdk/service-discovery` : agrégateur d'API reverse-proxy avec enregistrement et découverte de services au runtime. `DiscoveryClient` (`./client`) enregistre un service et maintient un heartbeat périodique ; `ServiceAggregator` (`./aggregator`) expose des handlers Node natifs à monter dans un serveur existant (Express, Fastify ou `node:http`), route les requêtes entrantes vers le bon service selon le préfixe de nom, et évince les enregistrements expirés (TTL). Zéro dépendance framework HTTP. diff --git a/packages/server/service-discovery/README.md b/packages/server/service-discovery/README.md new file mode 100644 index 0000000..ff2ea66 --- /dev/null +++ b/packages/server/service-discovery/README.md @@ -0,0 +1,114 @@ +# @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`. diff --git a/packages/server/service-discovery/aggregator/package.json b/packages/server/service-discovery/aggregator/package.json new file mode 100644 index 0000000..4600f55 --- /dev/null +++ b/packages/server/service-discovery/aggregator/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/aggregator/index.js", + "types": "../dist/aggregator/index.d.ts" +} diff --git a/packages/server/service-discovery/client/package.json b/packages/server/service-discovery/client/package.json new file mode 100644 index 0000000..eb18c54 --- /dev/null +++ b/packages/server/service-discovery/client/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/client/index.js", + "types": "../dist/client/index.d.ts" +} diff --git a/packages/server/service-discovery/package.json b/packages/server/service-discovery/package.json new file mode 100644 index 0000000..e15231d --- /dev/null +++ b/packages/server/service-discovery/package.json @@ -0,0 +1,32 @@ +{ + "name": "@lightweight-sdk/service-discovery", + "version": "0.0.1", + "description": "Agrégateur d'API reverse-proxy avec enregistrement et découverte de services au runtime.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "client", + "aggregator" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "restricted", + "registry": "https://git.dev.kyuno.fr/api/packages/kyuno/npm/" + }, + "typedocOptions": { + "entryPoints": [ + "src/index.ts", + "src/client/index.ts", + "src/aggregator/index.ts" + ], + "readme": "README.md" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/server/service-discovery/src/aggregator/http-utils.test.ts b/packages/server/service-discovery/src/aggregator/http-utils.test.ts new file mode 100644 index 0000000..44679bd --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/http-utils.test.ts @@ -0,0 +1,59 @@ +import type { IncomingMessage } from 'node:http'; +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { filterHopByHop, readJsonBody, timingSafeEqualStrings } from './http-utils'; + +function fakeRequest(chunks: string[]): IncomingMessage { + return Readable.from( + chunks.map((chunk) => Buffer.from(chunk, 'utf8')), + ) as unknown as IncomingMessage; +} + +describe('timingSafeEqualStrings', () => { + it('returns true for equal strings', () => { + expect(timingSafeEqualStrings('shh', 'shh')).toBe(true); + }); + + it('returns false for different strings of the same length', () => { + expect(timingSafeEqualStrings('shh1', 'shh2')).toBe(false); + }); + + it('returns false for strings of different lengths', () => { + expect(timingSafeEqualStrings('short', 'a-much-longer-string')).toBe(false); + }); +}); + +describe('readJsonBody', () => { + it('parses a valid JSON body', async () => { + await expect(readJsonBody(fakeRequest(['{"hello":"world"}']), 1024)).resolves.toEqual({ + hello: 'world', + }); + }); + + it('resolves to undefined for an empty body', async () => { + await expect(readJsonBody(fakeRequest([]), 1024)).resolves.toBeUndefined(); + }); + + it('throws on malformed JSON', async () => { + await expect(readJsonBody(fakeRequest(['not json']), 1024)).rejects.toThrow(); + }); + + it('throws when the body exceeds the byte limit', async () => { + await expect( + readJsonBody(fakeRequest(['{"padding":"'.padEnd(50, 'x') + '"}']), 10), + ).rejects.toThrow(/exceeds/); + }); +}); + +describe('filterHopByHop', () => { + it('removes hop-by-hop headers while keeping the rest', () => { + const filtered = filterHopByHop({ + connection: 'keep-alive', + 'transfer-encoding': 'chunked', + 'content-type': 'application/json', + 'x-custom': 'value', + }); + + expect(filtered).toEqual({ 'content-type': 'application/json', 'x-custom': 'value' }); + }); +}); diff --git a/packages/server/service-discovery/src/aggregator/http-utils.ts b/packages/server/service-discovery/src/aggregator/http-utils.ts new file mode 100644 index 0000000..7d13c94 --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/http-utils.ts @@ -0,0 +1,68 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; + +/** Headers that are only meaningful between two adjacent hops (RFC 7230 §6.1) and must never be blindly forwarded by a proxy. */ +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', +]); + +/** + * Compares two strings in constant time, to avoid leaking information about + * a secret's value through response-time differences. Costs nothing (uses + * only `node:crypto`, already a runtime dependency of this package) so + * there is no reason to prefer a plain `===` here. + * @param a first string to compare + * @param b second string to compare + * @returns `true` if `a` and `b` are exactly equal + */ +export function timingSafeEqualStrings(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB); +} + +/** + * Reads and JSON-parses the body of an incoming request, enforcing a + * maximum size to avoid unbounded memory use from a misbehaving or + * malicious caller. + * @param req the incoming request to read + * @param maxBytes maximum accepted body size, in bytes + * @returns the parsed JSON body + * @throws {Error} if the body exceeds `maxBytes` or is not valid JSON + */ +export async function readJsonBody(req: IncomingMessage, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of req as AsyncIterable) { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + throw new Error(`request body exceeds the ${maxBytes} byte limit`); + } + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString('utf8'); + return raw.length === 0 ? undefined : JSON.parse(raw); +} + +/** + * Returns a copy of `headers` with hop-by-hop headers removed, safe to + * forward across a proxy hop in either direction. + * @param headers the headers to filter + * @returns a new headers object without hop-by-hop entries + */ +export function filterHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders { + const filtered: IncomingHttpHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + filtered[key] = value; + } + } + return filtered; +} diff --git a/packages/server/service-discovery/src/aggregator/index.ts b/packages/server/service-discovery/src/aggregator/index.ts new file mode 100644 index 0000000..e9a367d --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/index.ts @@ -0,0 +1,3 @@ +export type { HttpMethod, RegistrationAck, ServiceEndpoint } from '../types'; +export type { ServiceAggregatorOptions } from './service-aggregator'; +export { ServiceAggregator } from './service-aggregator'; diff --git a/packages/server/service-discovery/src/aggregator/proxy-request.test.ts b/packages/server/service-discovery/src/aggregator/proxy-request.test.ts new file mode 100644 index 0000000..f00c55a --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/proxy-request.test.ts @@ -0,0 +1,141 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServer as createHttpServer, Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { EphemeralServer } from '../test-support/ephemeral-http-server'; +import { startEphemeralServer } from '../test-support/ephemeral-http-server'; +import { proxyRequest } from './proxy-request'; +import type { RegisteredService } from './registered-service'; +import type { RouteMatch } from './route-matching'; + +const httpAgent = new HttpAgent({ keepAlive: false }); +const httpsAgent = new HttpsAgent({ keepAlive: false }); + +/** Starts a proxy-fronting server that relays every request to `match` via `proxyRequest`. */ +async function startProxyFront(match: RouteMatch, timeoutMs = 2000): Promise { + return startEphemeralServer((req: IncomingMessage, res: ServerResponse) => { + proxyRequest(req, res, match, { timeoutMs, httpAgent, httpsAgent }); + }); +} + +function matchFor(service: RegisteredService, remainderPath: string): RouteMatch { + return { serviceName: 'backend', service, remainderPath }; +} + +describe('proxyRequest', () => { + const servers: EphemeralServer[] = []; + afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => s.close())); + }); + + it('relays a GET request, stripping the service prefix and forwarding query/headers', async () => { + const backend = await startEphemeralServer((req, res) => { + res.writeHead(200, { + 'content-type': 'application/json', + 'x-echo': String(req.headers['x-request-id']), + }); + res.end(JSON.stringify({ path: req.url })); + }); + servers.push(backend); + const service: RegisteredService = { + baseUrl: backend.url, + endpoints: [], + expiresAt: Date.now() + 60_000, + }; + const front = await startProxyFront(matchFor(service, '/users')); + servers.push(front); + + const response = await fetch(`${front.url}/my-service/users?active=true`, { + headers: { 'x-request-id': 'abc' }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get('x-echo')).toBe('abc'); + expect(await response.json()).toEqual({ path: '/users?active=true' }); + }); + + it('relays a POST request with a JSON body intact', async () => { + const backend = await startEphemeralServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(Buffer.concat(chunks)); + }); + }); + servers.push(backend); + const service: RegisteredService = { + baseUrl: backend.url, + endpoints: [], + expiresAt: Date.now() + 60_000, + }; + const front = await startProxyFront(matchFor(service, '/users')); + servers.push(front); + + const response = await fetch(`${front.url}/my-service/users`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'ada' }), + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ name: 'ada' }); + }); + + it('sends the backend host in the Host header, not the front-facing aggregator host', async () => { + const backend = await startEphemeralServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(req.headers.host ?? ''); + }); + servers.push(backend); + const service: RegisteredService = { + baseUrl: backend.url, + endpoints: [], + expiresAt: Date.now() + 60_000, + }; + const front = await startProxyFront(matchFor(service, '/')); + servers.push(front); + + const response = await fetch(`${front.url}/my-service`); + + expect(await response.text()).toBe(`127.0.0.1:${backend.port}`); + }); + + it('responds 502 when the backend refuses the connection', async () => { + const closed = await startEphemeralServer(() => {}); + const deadPort = closed.port; + await closed.close(); + const service: RegisteredService = { + baseUrl: `http://127.0.0.1:${deadPort}`, + endpoints: [], + expiresAt: Date.now() + 60_000, + }; + const front = await startProxyFront(matchFor(service, '/')); + servers.push(front); + + const response = await fetch(`${front.url}/my-service`); + + expect(response.status).toBe(502); + }); + + it('responds 504 when the backend accepts the connection but never replies', async () => { + const backend = createHttpServer(() => { + // intentionally never respond + }); + await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve)); + const address = backend.address(); + if (address === null || typeof address === 'string') throw new Error('expected a TCP address'); + const service: RegisteredService = { + baseUrl: `http://127.0.0.1:${address.port}`, + endpoints: [], + expiresAt: Date.now() + 60_000, + }; + const front = await startProxyFront(matchFor(service, '/'), 200); + servers.push(front); + + const response = await fetch(`${front.url}/my-service`); + + expect(response.status).toBe(504); + await new Promise((resolve, reject) => backend.close((e) => (e ? reject(e) : resolve()))); + }); +}); diff --git a/packages/server/service-discovery/src/aggregator/proxy-request.ts b/packages/server/service-discovery/src/aggregator/proxy-request.ts new file mode 100644 index 0000000..3274061 --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/proxy-request.ts @@ -0,0 +1,85 @@ +import type { Agent as HttpAgent, IncomingMessage, ServerResponse } from 'node:http'; +import { request as httpRequest } from 'node:http'; +import type { Agent as HttpsAgent } from 'node:https'; +import { request as httpsRequest } from 'node:https'; +import { filterHopByHop } from './http-utils'; +import type { RouteMatch } from './route-matching'; + +const LOG_PREFIX = '[lightweight-sdk:service-discovery:aggregator]'; + +/** Options controlling how {@link proxyRequest} relays a request to its matched backend. */ +export interface ProxyOptions { + /** Socket inactivity timeout for the backend request, in milliseconds. Not a total duration cap. */ + timeoutMs: number; + /** Keep-alive agent to use for `http:` targets. */ + httpAgent: HttpAgent; + /** Keep-alive agent to use for `https:` targets. */ + httpsAgent: HttpsAgent; +} + +/** + * Relays `req` to the backend service identified by `match`, streaming the + * request and response bodies through with `node:http`/`node:https` — no + * buffering, no third-party proxy dependency. Responds directly on `res`; + * does not return a value the caller needs to act on. + * @param req the incoming request to relay + * @param res the response to write the backend's reply to + * @param match the route match identifying the backend to relay to + * @param options proxy behaviour options + */ +export function proxyRequest( + req: IncomingMessage, + res: ServerResponse, + match: RouteMatch, + options: ProxyOptions, +): void { + const search = req.url?.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; + const targetUrl = new URL(`${match.remainderPath}${search}`, match.service.baseUrl); + const isHttps = targetUrl.protocol === 'https:'; + const transportRequest = isHttps ? httpsRequest : httpRequest; + let timedOut = false; + + const proxyReq = transportRequest( + { + hostname: targetUrl.hostname, + port: targetUrl.port, + path: `${targetUrl.pathname}${targetUrl.search}`, + method: req.method, + headers: { ...filterHopByHop(req.headers), host: targetUrl.host }, + timeout: options.timeoutMs, + agent: isHttps ? options.httpsAgent : options.httpAgent, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, filterHopByHop(proxyRes.headers)); + proxyRes.pipe(res); + }, + ); + + proxyReq.on('timeout', () => { + timedOut = true; + proxyReq.destroy(); + }); + + proxyReq.on('error', (error) => { + console.error(`${LOG_PREFIX} relay to "${targetUrl.origin}" failed`, error); + if (!res.headersSent) { + res.writeHead(timedOut ? 504 : 502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: timedOut ? 'gateway timeout' : 'bad gateway' })); + } else { + res.destroy(); + } + }); + + // Listening on `res` (not `req`) matters: `req`'s 'close' fires as soon as + // the incoming request body has been fully read — immediately for a + // bodyless GET, well before the backend has replied — which would abort + // the outbound request we just sent. `res` only closes early if the + // original caller actually disconnects before the response completes. + res.on('close', () => { + if (!res.writableEnded) { + proxyReq.destroy(); + } + }); + + req.pipe(proxyReq); +} diff --git a/packages/server/service-discovery/src/aggregator/registered-service.ts b/packages/server/service-discovery/src/aggregator/registered-service.ts new file mode 100644 index 0000000..bf0c92c --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/registered-service.ts @@ -0,0 +1,11 @@ +import type { ServiceEndpoint } from '../types'; + +/** Internal record kept by {@link ServiceAggregator} for each registered service. */ +export interface RegisteredService { + /** URL of base which the aggregator proxies matched requests to. */ + baseUrl: string; + /** Endpoints this service declared at registration time. */ + endpoints: ServiceEndpoint[]; + /** Epoch milliseconds at which this registration expires without a heartbeat. */ + expiresAt: number; +} diff --git a/packages/server/service-discovery/src/aggregator/route-matching.test.ts b/packages/server/service-discovery/src/aggregator/route-matching.test.ts new file mode 100644 index 0000000..40cd0c4 --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/route-matching.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import type { RegisteredService } from './registered-service'; +import { matchRoute } from './route-matching'; + +function registryWith(entries: Record): Map { + return new Map(Object.entries(entries)); +} + +const activeService: RegisteredService = { + baseUrl: 'http://127.0.0.1:9999', + endpoints: [{ method: 'GET', path: '/users' }], + expiresAt: Date.now() + 60_000, +}; + +describe('matchRoute', () => { + it('matches a registered endpoint under the service-name prefix', () => { + const registry = registryWith({ 'my-service': activeService }); + + const match = matchRoute(registry, 'GET', '/my-service/users'); + + expect(match).toEqual({ + serviceName: 'my-service', + service: activeService, + remainderPath: '/users', + }); + }); + + it('returns undefined for an unknown service', () => { + const registry = registryWith({ 'my-service': activeService }); + + expect(matchRoute(registry, 'GET', '/other-service/users')).toBeUndefined(); + }); + + it('returns undefined when the method does not match', () => { + const registry = registryWith({ 'my-service': activeService }); + + expect(matchRoute(registry, 'POST', '/my-service/users')).toBeUndefined(); + }); + + it('returns undefined when the path is not declared', () => { + const registry = registryWith({ 'my-service': activeService }); + + expect(matchRoute(registry, 'GET', '/my-service/unknown')).toBeUndefined(); + }); + + it('returns undefined for an expired registration, even if still present', () => { + const expired: RegisteredService = { ...activeService, expiresAt: Date.now() - 1 }; + const registry = registryWith({ 'my-service': expired }); + + expect(matchRoute(registry, 'GET', '/my-service/users')).toBeUndefined(); + }); + + it('returns undefined for the root path', () => { + const registry = registryWith({ 'my-service': activeService }); + + expect(matchRoute(registry, 'GET', '/')).toBeUndefined(); + }); + + it('matches the service root endpoint ("/") correctly', () => { + const rootService: RegisteredService = { + ...activeService, + endpoints: [{ method: 'GET', path: '/' }], + }; + const registry = registryWith({ 'my-service': rootService }); + + expect(matchRoute(registry, 'GET', '/my-service')).toEqual({ + serviceName: 'my-service', + service: rootService, + remainderPath: '/', + }); + }); +}); diff --git a/packages/server/service-discovery/src/aggregator/route-matching.ts b/packages/server/service-discovery/src/aggregator/route-matching.ts new file mode 100644 index 0000000..c33b06d --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/route-matching.ts @@ -0,0 +1,45 @@ +import type { RegisteredService } from './registered-service'; + +/** Result of a successful {@link matchRoute} call. */ +export interface RouteMatch { + /** Name of the service the incoming request was routed to. */ + serviceName: string; + /** The matched service's registration record. */ + service: RegisteredService; + /** Path to forward to the service, with its name prefix stripped (e.g. "/users"). */ + remainderPath: string; +} + +/** + * Matches an incoming request against the registry, using a "service name as + * first path segment" scheme: `GET /my-service/users` matches a service + * registered as `my-service` with an endpoint `GET /users`. Unknown + * services, expired registrations, and known services without a matching + * endpoint all resolve to `undefined` alike — callers surface this as a 404. + * @param registry the current service registry, keyed by service name + * @param method HTTP method of the incoming request + * @param pathname pathname of the incoming request (no query string) + * @returns the matched route, or `undefined` if nothing matches + */ +export function matchRoute( + registry: ReadonlyMap, + method: string, + pathname: string, +): RouteMatch | undefined { + const segments = pathname.split('/').filter((segment) => segment.length > 0); + if (segments.length === 0) { + return undefined; + } + + const [serviceName, ...rest] = segments; + const service = registry.get(serviceName); + if (service === undefined || service.expiresAt <= Date.now()) { + return undefined; + } + + const remainderPath = `/${rest.join('/')}`; + const matched = service.endpoints.some( + (endpoint) => endpoint.method === method && endpoint.path === remainderPath, + ); + return matched ? { serviceName, service, remainderPath } : undefined; +} diff --git a/packages/server/service-discovery/src/aggregator/service-aggregator.test.ts b/packages/server/service-discovery/src/aggregator/service-aggregator.test.ts new file mode 100644 index 0000000..3bd0848 --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/service-aggregator.test.ts @@ -0,0 +1,144 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EphemeralServer } from '../test-support/ephemeral-http-server'; +import { startEphemeralServer } from '../test-support/ephemeral-http-server'; +import { ServiceAggregator } from './service-aggregator'; + +const SECRET = 'shh'; +const PAYLOAD = { + serviceName: 'my-service', + baseUrl: 'http://127.0.0.1:9999', + endpoints: [{ method: 'GET', path: '/users' }], +}; + +async function startControlPlane(aggregator: ServiceAggregator): Promise { + return startEphemeralServer((req: IncomingMessage, res: ServerResponse) => { + if (req.url === '/register') { + void aggregator.registrationHandler(req, res); + return; + } + if (req.url === '/deregister') { + void aggregator.deregistrationHandler(req, res); + return; + } + void aggregator.proxyHandler(req, res); + }); +} + +// `secret: null` (as opposed to the omitted/default case) means "send no +// Authorization header at all" — using `undefined` for that would silently +// fall back to the default parameter instead of actually omitting it. +function register(url: string, body: unknown, secret: string | null = SECRET) { + return fetch(`${url}/register`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(secret === null ? {} : { authorization: `Bearer ${secret}` }), + }, + body: JSON.stringify(body), + }); +} + +function deregister(url: string, serviceName: string, secret: string | null = SECRET) { + return fetch(`${url}/deregister`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(secret === null ? {} : { authorization: `Bearer ${secret}` }), + }, + body: JSON.stringify({ serviceName }), + }); +} + +describe('ServiceAggregator', () => { + let aggregator: ServiceAggregator; + let front: EphemeralServer; + + beforeEach(async () => { + aggregator = new ServiceAggregator({ sharedSecret: SECRET, ttlMs: 1000, sweepIntervalMs: 500 }); + front = await startControlPlane(aggregator); + }); + + afterEach(async () => { + aggregator.stop(); + await front.close(); + }); + + it('registers a valid payload and lists it', async () => { + const response = await register(front.url, PAYLOAD); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, expiresInMs: 1000 }); + expect(aggregator.getRegisteredServiceNames()).toEqual(['my-service']); + }); + + it('rejects registration with a missing secret', async () => { + const response = await register(front.url, PAYLOAD, null); + expect(response.status).toBe(401); + }); + + it('rejects registration with a wrong secret', async () => { + const response = await register(front.url, PAYLOAD, 'wrong'); + expect(response.status).toBe(401); + }); + + it('rejects a malformed payload with 400', async () => { + const response = await register(front.url, { serviceName: 'x' }); // missing baseUrl/endpoints + expect(response.status).toBe(400); + }); + + it('treats re-registration under the same name as an upsert, not a duplicate error', async () => { + await register(front.url, PAYLOAD); + const second = await register(front.url, PAYLOAD); + + expect(second.status).toBe(200); + expect(aggregator.getRegisteredServiceNames()).toEqual(['my-service']); + }); + + it('renews the TTL on heartbeat, surviving past the original expiry', async () => { + vi.useFakeTimers(); + try { + await register(front.url, PAYLOAD); + await vi.advanceTimersByTimeAsync(900); // close to the 1000ms TTL + await register(front.url, PAYLOAD); // heartbeat renews it + await vi.advanceTimersByTimeAsync(600); // past the *original* expiry, not the renewed one + + expect(aggregator.getRegisteredServiceNames()).toEqual(['my-service']); + } finally { + vi.useRealTimers(); + } + }); + + it('evicts an expired registration once the sweep runs', async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + aggregator.start(); + await register(front.url, PAYLOAD); + await vi.advanceTimersByTimeAsync(1600); // past ttlMs (1000) and one sweep tick (500) + + expect(aggregator.getRegisteredServiceNames()).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('evicted')); + } finally { + vi.useRealTimers(); + } + }); + + it('deregisters a service immediately, before TTL expiry', async () => { + await register(front.url, PAYLOAD); + const response = await deregister(front.url, 'my-service'); + + expect(response.status).toBe(204); + expect(aggregator.getRegisteredServiceNames()).toEqual([]); + }); + + it('treats deregistering an unknown service as a no-op', async () => { + const response = await deregister(front.url, 'unknown-service'); + expect(response.status).toBe(204); + }); + + it('rejects deregistration without a valid secret, same as registration', async () => { + const response = await deregister(front.url, 'my-service', 'wrong'); + expect(response.status).toBe(401); + }); +}); diff --git a/packages/server/service-discovery/src/aggregator/service-aggregator.ts b/packages/server/service-discovery/src/aggregator/service-aggregator.ts new file mode 100644 index 0000000..3b47694 --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/service-aggregator.ts @@ -0,0 +1,185 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; +import type { RegistrationAck } from '../types'; +import { readJsonBody, timingSafeEqualStrings } from './http-utils'; +import { proxyRequest } from './proxy-request'; +import type { RegisteredService } from './registered-service'; +import { matchRoute } from './route-matching'; +import { + validateDeregistrationPayload, + validateRegistrationPayload, +} from './validate-registration'; + +const LOG_PREFIX = '[lightweight-sdk:service-discovery:aggregator]'; + +const DEFAULT_TTL_MS = 45_000; +const DEFAULT_SWEEP_INTERVAL_MS = 10_000; +const DEFAULT_PROXY_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_REGISTRATION_BODY_BYTES = 65_536; + +/** Configuration options for a {@link ServiceAggregator}. */ +export interface ServiceAggregatorOptions { + /** Secret that registering services must present via `Authorization: Bearer `. */ + sharedSecret: string; + /** How long a registration stays valid without a heartbeat, in milliseconds. + * @default 45000 */ + ttlMs?: number; + /** How often to sweep and evict expired registrations, in milliseconds. + * @default 10000 */ + sweepIntervalMs?: number; + /** Socket inactivity timeout for proxied requests to backends, in milliseconds. + * @default 10000 */ + proxyTimeoutMs?: number; + /** Maximum accepted size for a registration/deregistration body, in bytes. + * @default 65536 */ + maxRegistrationBodyBytes?: number; +} + +/** + * Reverse-proxy API aggregator: services register themselves (and their + * endpoints) at runtime, and the aggregator relays matching incoming + * requests to them. Exposes plain Node request handlers — mount them into + * an existing Express/Fastify/`node:http` server, this class never listens + * on a port itself. + */ +export class ServiceAggregator { + private readonly options: ServiceAggregatorOptions; + private readonly registry = new Map(); + private readonly httpAgent = new HttpAgent({ keepAlive: true }); + private readonly httpsAgent = new HttpsAgent({ keepAlive: true }); + private sweepTimer: NodeJS.Timeout | undefined; + + public constructor(options: ServiceAggregatorOptions) { + this.options = options; + } + + /** Handles a service registration or heartbeat request. Mount on whatever path you choose (e.g. `POST /discovery/register`). */ + public readonly registrationHandler = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + if (!this.isAuthorized(req)) { + this.respondJson(res, 401, { error: 'unauthorized' }); + return; + } + try { + const body = await readJsonBody( + req, + this.options.maxRegistrationBodyBytes ?? DEFAULT_MAX_REGISTRATION_BODY_BYTES, + ); + const payload = validateRegistrationPayload(body); + const ttlMs = this.options.ttlMs ?? DEFAULT_TTL_MS; + this.registry.set(payload.serviceName, { + baseUrl: payload.baseUrl, + endpoints: payload.endpoints, + expiresAt: Date.now() + ttlMs, + }); + console.debug( + `${LOG_PREFIX} registered "${payload.serviceName}" (${payload.endpoints.length} endpoint(s))`, + ); + this.respondJson(res, 200, { ok: true, expiresInMs: ttlMs } satisfies RegistrationAck); + } catch (error) { + console.error(`${LOG_PREFIX} registration failed`, error); + this.respondJson(res, 400, { error: 'invalid registration payload' }); + } + }; + + /** Handles a best-effort service deregistration request. Mount on whatever path you choose (e.g. `POST /discovery/deregister`). */ + public readonly deregistrationHandler = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + if (!this.isAuthorized(req)) { + this.respondJson(res, 401, { error: 'unauthorized' }); + return; + } + try { + const body = await readJsonBody( + req, + this.options.maxRegistrationBodyBytes ?? DEFAULT_MAX_REGISTRATION_BODY_BYTES, + ); + const payload = validateDeregistrationPayload(body); + if (this.registry.delete(payload.serviceName)) { + console.debug(`${LOG_PREFIX} deregistered "${payload.serviceName}"`); + } + res.writeHead(204); + res.end(); + } catch (error) { + console.error(`${LOG_PREFIX} deregistration failed`, error); + this.respondJson(res, 400, { error: 'invalid deregistration payload' }); + } + }; + + /** Handles a request that should be relayed to a registered service. Mount as a catch-all, after your own routes and the two handlers above. */ + public readonly proxyHandler = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + const pathname = (req.url ?? '/').split('?')[0] ?? '/'; + const match = matchRoute(this.registry, req.method ?? 'GET', pathname); + if (match === undefined) { + this.respondJson(res, 404, { error: 'no service registered for this route' }); + return; + } + proxyRequest(req, res, match, { + timeoutMs: this.options.proxyTimeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS, + httpAgent: this.httpAgent, + httpsAgent: this.httpsAgent, + }); + }; + + /** Starts the periodic sweep that evicts expired registrations. */ + public start(): void { + if (this.sweepTimer !== undefined) { + return; + } + this.sweepTimer = setInterval( + () => this.sweepExpired(), + this.options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS, + ); + this.sweepTimer.unref(); + } + + /** Stops the periodic sweep and releases the keep-alive connection pools. */ + public stop(): void { + if (this.sweepTimer !== undefined) { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + } + this.httpAgent.destroy(); + this.httpsAgent.destroy(); + } + + /** + * Lists the names of every currently registered (non-expired) service — + * useful for introspection/health checks in the host app. + * @returns the registered service names + */ + public getRegisteredServiceNames(): string[] { + return [...this.registry.keys()]; + } + + private isAuthorized(req: IncomingMessage): boolean { + const header = req.headers.authorization; + if (header === undefined || !header.startsWith('Bearer ')) { + return false; + } + return timingSafeEqualStrings(header.slice('Bearer '.length), this.options.sharedSecret); + } + + private respondJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + } + + private sweepExpired(): void { + const now = Date.now(); + for (const [name, service] of this.registry) { + if (service.expiresAt <= now) { + this.registry.delete(name); + console.warn(`${LOG_PREFIX} evicted expired service "${name}"`); + } + } + } +} diff --git a/packages/server/service-discovery/src/aggregator/validate-registration.test.ts b/packages/server/service-discovery/src/aggregator/validate-registration.test.ts new file mode 100644 index 0000000..ab9673c --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/validate-registration.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + validateDeregistrationPayload, + validateRegistrationPayload, +} from './validate-registration'; + +const validPayload = { + serviceName: 'my-service', + baseUrl: 'http://127.0.0.1:9999', + endpoints: [{ method: 'GET', path: '/users' }], +}; + +describe('validateRegistrationPayload', () => { + it('accepts a valid payload', () => { + expect(validateRegistrationPayload(validPayload)).toEqual(validPayload); + }); + + // Substring matching (not a RegExp): some messages contain regex + // metacharacters ("http(s)") that must be matched literally. + it.each([ + ['must be a JSON object', 'null payload', null], + // an array passes the typeof==='object' check; it then fails on the + // (undefined) destructured serviceName, same as an empty object would. + ['"serviceName" must be a string', 'array payload', []], + [ + '"serviceName" must be a string', + 'missing serviceName', + { ...validPayload, serviceName: undefined }, + ], + [ + 'Invalid service name', + 'invalid serviceName casing', + { ...validPayload, serviceName: 'Not-Kebab' }, + ], + ['"baseUrl" must be a string', 'missing baseUrl', { ...validPayload, baseUrl: 42 }], + ['valid http(s) URL', 'unparseable baseUrl', { ...validPayload, baseUrl: 'not a url' }], + ['valid http(s) URL', 'non-http baseUrl', { ...validPayload, baseUrl: 'ftp://127.0.0.1' }], + ['"endpoints" must be an array', 'non-array endpoints', { ...validPayload, endpoints: {} }], + [ + 'must be one of', + 'invalid endpoint method', + { ...validPayload, endpoints: [{ method: 'TRACE', path: '/x' }] }, + ], + [ + 'must start with "/"', + 'endpoint path without leading slash', + { ...validPayload, endpoints: [{ method: 'GET', path: 'x' }] }, + ], + ])('rejects %s (%s)', (expectedMessage, _label, payload) => { + expect(() => validateRegistrationPayload(payload)).toThrow(expectedMessage); + }); +}); + +describe('validateDeregistrationPayload', () => { + it('accepts a valid payload', () => { + expect(validateDeregistrationPayload({ serviceName: 'my-service' })).toEqual({ + serviceName: 'my-service', + }); + }); + + it('rejects a payload without a serviceName', () => { + expect(() => validateDeregistrationPayload({})).toThrow('"serviceName" must be a string'); + }); +}); diff --git a/packages/server/service-discovery/src/aggregator/validate-registration.ts b/packages/server/service-discovery/src/aggregator/validate-registration.ts new file mode 100644 index 0000000..b829a69 --- /dev/null +++ b/packages/server/service-discovery/src/aggregator/validate-registration.ts @@ -0,0 +1,94 @@ +import { assertValidServiceName } from '../service-name'; +import type { + DeregistrationPayload, + HttpMethod, + RegistrationPayload, + ServiceEndpoint, +} from '../types'; + +const HTTP_METHODS: ReadonlySet = new Set([ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +]); + +/** + * Validates and narrows an unknown parsed JSON value into a + * {@link RegistrationPayload}. + * @param payload the parsed JSON body to validate + * @returns the validated payload + * @throws {Error} with a descriptive message if `payload` does not match the expected shape + */ +export function validateRegistrationPayload(payload: unknown): RegistrationPayload { + if (typeof payload !== 'object' || payload === null) { + throw new Error('registration payload must be a JSON object'); + } + const { serviceName, baseUrl, endpoints } = payload as Record; + + if (typeof serviceName !== 'string') { + throw new Error('"serviceName" must be a string'); + } + assertValidServiceName(serviceName); + + if (typeof baseUrl !== 'string') { + throw new Error('"baseUrl" must be a string'); + } + const parsedBaseUrl = safeParseUrl(baseUrl); + if ( + parsedBaseUrl === undefined || + (parsedBaseUrl.protocol !== 'http:' && parsedBaseUrl.protocol !== 'https:') + ) { + throw new Error('"baseUrl" must be a valid http(s) URL'); + } + + if (!Array.isArray(endpoints)) { + throw new Error('"endpoints" must be an array'); + } + const validatedEndpoints = endpoints.map((endpoint, index) => validateEndpoint(endpoint, index)); + + return { serviceName, baseUrl, endpoints: validatedEndpoints }; +} + +/** + * Validates and narrows an unknown parsed JSON value into a + * {@link DeregistrationPayload}. + * @param payload the parsed JSON body to validate + * @returns the validated payload + * @throws {Error} with a descriptive message if `payload` does not match the expected shape + */ +export function validateDeregistrationPayload(payload: unknown): DeregistrationPayload { + if (typeof payload !== 'object' || payload === null) { + throw new Error('deregistration payload must be a JSON object'); + } + const { serviceName } = payload as Record; + if (typeof serviceName !== 'string') { + throw new Error('"serviceName" must be a string'); + } + return { serviceName }; +} + +function validateEndpoint(endpoint: unknown, index: number): ServiceEndpoint { + if (typeof endpoint !== 'object' || endpoint === null) { + throw new Error(`endpoints[${index}] must be an object`); + } + const { method, path } = endpoint as Record; + if (typeof method !== 'string' || !HTTP_METHODS.has(method)) { + throw new Error(`endpoints[${index}].method must be one of ${[...HTTP_METHODS].join(', ')}`); + } + if (typeof path !== 'string' || !path.startsWith('/')) { + throw new Error(`endpoints[${index}].path must start with "/"`); + } + return { method: method as HttpMethod, path }; +} + +function safeParseUrl(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} diff --git a/packages/server/service-discovery/src/client/discovery-client.test.ts b/packages/server/service-discovery/src/client/discovery-client.test.ts new file mode 100644 index 0000000..707f59e --- /dev/null +++ b/packages/server/service-discovery/src/client/discovery-client.test.ts @@ -0,0 +1,150 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EphemeralServer } from '../test-support/ephemeral-http-server'; +import { startEphemeralServer } from '../test-support/ephemeral-http-server'; +import type { RegistrationPayload } from '../types'; +import type { DiscoveryClientOptions } from './discovery-client'; +import { DiscoveryClient } from './discovery-client'; + +interface RecordedRequest { + method: string | undefined; + url: string | undefined; + authorization: string | undefined; + body: unknown; +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const raw = Buffer.concat(chunks).toString('utf8'); + return raw.length > 0 ? JSON.parse(raw) : undefined; +} + +/** Fake aggregator recording every request it receives, responding with `status`. */ +async function startRecordingAggregator( + status = 200, +): Promise { + const requests: RecordedRequest[] = []; + const server = await startEphemeralServer((req: IncomingMessage, res: ServerResponse) => { + void readBody(req).then((body) => { + requests.push({ + method: req.method, + url: req.url, + authorization: req.headers.authorization, + body, + }); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end('{}'); + }); + }); + return Object.assign(server, { requests }); +} + +describe('DiscoveryClient', () => { + let aggregator: Awaited>; + let client: DiscoveryClient | undefined; + + beforeEach(async () => { + vi.useFakeTimers(); + aggregator = await startRecordingAggregator(); + }); + + afterEach(async () => { + await client?.stop(); + client = undefined; + await aggregator.close(); + vi.useRealTimers(); + }); + + function createClient(overrides: Partial = {}) { + client = new DiscoveryClient({ + aggregatorUrl: aggregator.url, + serviceName: 'my-service', + baseUrl: 'http://127.0.0.1:9999', + endpoints: [{ method: 'GET', path: '/users' }], + sharedSecret: 'shh', + heartbeatIntervalMs: 1000, + ...overrides, + }); + return client; + } + + it('sends the initial registration with the payload and the Authorization header', async () => { + await createClient().start(); + + expect(aggregator.requests).toHaveLength(1); + expect(aggregator.requests[0]?.method).toBe('POST'); + expect(aggregator.requests[0]?.url).toBe('/discovery/register'); + expect(aggregator.requests[0]?.authorization).toBe('Bearer shh'); + expect(aggregator.requests[0]?.body).toEqual({ + serviceName: 'my-service', + baseUrl: 'http://127.0.0.1:9999', + endpoints: [{ method: 'GET', path: '/users' }], + } satisfies RegistrationPayload); + }); + + it('sends a heartbeat on every interval tick', async () => { + await createClient().start(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + expect(aggregator.requests).toHaveLength(3); // initial + 2 heartbeats + }); + + it('logs a failed heartbeat and keeps heartbeating afterwards', async () => { + await aggregator.close(); + aggregator = await startRecordingAggregator(500); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await createClient().start(); + expect(errorSpy).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1000); + expect(aggregator.requests).toHaveLength(2); // still ticking despite the 500s + }); + + it('stops sending heartbeats after stop()', async () => { + const c = createClient(); + await c.start(); + await c.stop(); + client = undefined; // already stopped, afterEach should not double-stop + + const countAfterStop = aggregator.requests.length; + await vi.advanceTimersByTimeAsync(5000); + expect(aggregator.requests).toHaveLength(countAfterStop); + }); + + it('sends a best-effort deregistration on stop()', async () => { + const c = createClient(); + await c.start(); + await c.stop(); + client = undefined; + + const deregistration = aggregator.requests.find((r) => r.url === '/discovery/deregister'); + expect(deregistration).toBeDefined(); + expect(deregistration?.body).toEqual({ serviceName: 'my-service' }); + }); + + it('never rejects on stop() even if deregistration fails', async () => { + const c = createClient({ aggregatorUrl: 'http://127.0.0.1:1' }); // nothing listens here + await expect(c.stop()).resolves.toBeUndefined(); + client = undefined; + }); + + it('does not throw when stop() is called without a prior start()', async () => { + const c = createClient(); + await expect(c.stop()).resolves.toBeUndefined(); + client = undefined; + }); + + it('does not start a second heartbeat loop when start() is called twice', async () => { + const c = createClient(); + await c.start(); + await c.start(); + await vi.advanceTimersByTimeAsync(1000); + + expect(aggregator.requests).toHaveLength(2); // initial + 1 heartbeat, not 3 + }); +}); diff --git a/packages/server/service-discovery/src/client/discovery-client.ts b/packages/server/service-discovery/src/client/discovery-client.ts new file mode 100644 index 0000000..deb6aac --- /dev/null +++ b/packages/server/service-discovery/src/client/discovery-client.ts @@ -0,0 +1,151 @@ +import { assertValidServiceName } from '../service-name'; +import type { DeregistrationPayload, RegistrationPayload, ServiceEndpoint } from '../types'; + +const LOG_PREFIX = '[lightweight-sdk:service-discovery:client]'; + +/** Configuration options for a {@link DiscoveryClient}. */ +export interface DiscoveryClientOptions { + /** Base URL of the aggregator to register with (e.g. "http://localhost:8080"). */ + aggregatorUrl: string; + /** Unique, stable name for this service — also becomes its public routing prefix. */ + serviceName: string; + /** Base URL the aggregator will proxy matched requests to. */ + baseUrl: string; + /** Endpoints this service exposes. */ + endpoints: ServiceEndpoint[]; + /** Secret presented to the aggregator to authenticate registration. */ + sharedSecret: string; + /** How often to re-send the registration as a heartbeat, in milliseconds. + * @default 15000 */ + heartbeatIntervalMs?: number; + /** Timeout for each registration/heartbeat/deregistration request, in milliseconds. + * @default 5000 */ + requestTimeoutMs?: number; + /** Path on the aggregator that handles registration. + * @default '/discovery/register' */ + registerPath?: string; + /** Path on the aggregator that handles deregistration. + * @default '/discovery/deregister' */ + deregisterPath?: string; +} + +const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 5_000; +const DEFAULT_REGISTER_PATH = '/discovery/register'; +const DEFAULT_DEREGISTER_PATH = '/discovery/deregister'; + +/** + * Registers a service with a ServiceAggregator (`@lightweight-sdk/service-discovery/aggregator`) and keeps the + * registration alive with a periodic heartbeat. The heartbeat is also the + * retry mechanism: a failed registration or heartbeat is logged and left to + * the next scheduled tick, rather than retried immediately — this covers + * both "aggregator not up yet" and transient outages without extra logic. + */ +export class DiscoveryClient { + private readonly options: DiscoveryClientOptions; + private readonly registerUrl: string; + private readonly deregisterUrl: string; + private heartbeatTimer: NodeJS.Timeout | undefined; + + /** + * @param options configuration for this client + * @throws {Error} if `options.serviceName` is not valid kebab-case + */ + public constructor(options: DiscoveryClientOptions) { + assertValidServiceName(options.serviceName); + this.options = options; + this.registerUrl = new URL( + options.registerPath ?? DEFAULT_REGISTER_PATH, + options.aggregatorUrl, + ).toString(); + this.deregisterUrl = new URL( + options.deregisterPath ?? DEFAULT_DEREGISTER_PATH, + options.aggregatorUrl, + ).toString(); + } + + /** + * Sends the initial registration and starts the periodic heartbeat. Never + * rejects: a failed initial registration is logged and retried on the + * next heartbeat tick, same as any other heartbeat failure. + */ + public async start(): Promise { + if (this.heartbeatTimer !== undefined) { + console.warn(`${LOG_PREFIX} start() called while already started — ignored`); + return; + } + await this.sendHeartbeat(); + this.heartbeatTimer = setInterval(() => { + this.sendHeartbeat().catch((error: unknown) => { + console.error(`${LOG_PREFIX} unexpected heartbeat error`, error); + }); + }, this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS); + this.heartbeatTimer.unref(); + } + + /** + * Stops the heartbeat and attempts a best-effort deregistration. Never + * throws: a failed deregistration is logged as a warning (the aggregator + * will evict this service via TTL regardless), so callers can always + * `await stop()` during a graceful shutdown without extra error handling. + */ + public async stop(): Promise { + if (this.heartbeatTimer !== undefined) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + try { + await this.sendDeregistration(); + } catch (error) { + console.warn( + `${LOG_PREFIX} best-effort deregistration failed — aggregator will evict via TTL`, + error, + ); + } + } + + private async sendHeartbeat(): Promise { + try { + const response = await fetch(this.registerUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.options.sharedSecret}`, + }, + body: JSON.stringify(this.buildPayload()), + signal: AbortSignal.timeout(this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`aggregator responded ${response.status}`); + } + console.debug(`${LOG_PREFIX} heartbeat ok for "${this.options.serviceName}"`); + } catch (error) { + console.error(`${LOG_PREFIX} heartbeat failed for "${this.options.serviceName}"`, error); + } + } + + private async sendDeregistration(): Promise { + const response = await fetch(this.deregisterUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.options.sharedSecret}`, + }, + body: JSON.stringify({ + serviceName: this.options.serviceName, + } satisfies DeregistrationPayload), + signal: AbortSignal.timeout(this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`aggregator responded ${response.status}`); + } + } + + private buildPayload(): RegistrationPayload { + return { + serviceName: this.options.serviceName, + baseUrl: this.options.baseUrl, + endpoints: this.options.endpoints, + }; + } +} diff --git a/packages/server/service-discovery/src/client/index.ts b/packages/server/service-discovery/src/client/index.ts new file mode 100644 index 0000000..9b7babc --- /dev/null +++ b/packages/server/service-discovery/src/client/index.ts @@ -0,0 +1,3 @@ +export type { HttpMethod, ServiceEndpoint } from '../types'; +export type { DiscoveryClientOptions } from './discovery-client'; +export { DiscoveryClient } from './discovery-client'; diff --git a/packages/server/service-discovery/src/index.ts b/packages/server/service-discovery/src/index.ts new file mode 100644 index 0000000..52301c4 --- /dev/null +++ b/packages/server/service-discovery/src/index.ts @@ -0,0 +1,7 @@ +export type { + DeregistrationPayload, + HttpMethod, + RegistrationAck, + RegistrationPayload, + ServiceEndpoint, +} from './types'; diff --git a/packages/server/service-discovery/src/service-name.ts b/packages/server/service-discovery/src/service-name.ts new file mode 100644 index 0000000..03e4d9d --- /dev/null +++ b/packages/server/service-discovery/src/service-name.ts @@ -0,0 +1,17 @@ +const SERVICE_NAME_PATTERN = /^[a-z][a-z0-9-]*[a-z0-9]$/; + +/** + * Validates that `serviceName` is a valid service identifier (kebab-case), + * safe to use as a URL path segment for routing. Shared between the client + * (fail-fast at construction) and the aggregator (registration payload + * validation). + * @param serviceName the candidate service name + * @throws {Error} if `serviceName` does not match the expected kebab-case format + */ +export function assertValidServiceName(serviceName: string): void { + if (!SERVICE_NAME_PATTERN.test(serviceName)) { + throw new Error( + `Invalid service name "${serviceName}": expected kebab-case (e.g. "my-service").`, + ); + } +} diff --git a/packages/server/service-discovery/src/test-support/ephemeral-http-server.ts b/packages/server/service-discovery/src/test-support/ephemeral-http-server.ts new file mode 100644 index 0000000..998c7d4 --- /dev/null +++ b/packages/server/service-discovery/src/test-support/ephemeral-http-server.ts @@ -0,0 +1,39 @@ +import type { RequestListener } from 'node:http'; +import { createServer } from 'node:http'; + +/** A running ephemeral HTTP server started by {@link startEphemeralServer}. */ +export interface EphemeralServer { + /** Base URL of the running server (e.g. "http://127.0.0.1:54321"). */ + url: string; + /** Port the server is listening on. */ + port: number; + /** Stops the server and waits until the underlying socket is closed. */ + close: () => Promise; +} + +/** + * Starts a real `node:http` server on a random free port. Used across the + * test suite as a stand-in aggregator or backend service, instead of + * mocking `fetch`/`node:http` — this exercises the real proxy/HTTP + * plumbing rather than a mocked approximation of it. + * @param handler request handler for the ephemeral server + * @returns the running server's URL/port and a `close` function + */ +export async function startEphemeralServer(handler: RequestListener): Promise { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected the ephemeral server to bind to a TCP address'); + } + return { + url: `http://127.0.0.1:${address.port}`, + port: address.port, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} diff --git a/packages/server/service-discovery/src/types.ts b/packages/server/service-discovery/src/types.ts new file mode 100644 index 0000000..9f7bc4b --- /dev/null +++ b/packages/server/service-discovery/src/types.ts @@ -0,0 +1,37 @@ +/** Méthodes HTTP qu'un endpoint de service enregistré peut exposer. */ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; + +/** Un endpoint exposé par un service, relatif à son `baseUrl`. */ +export interface ServiceEndpoint { + /** Méthode HTTP à laquelle cet endpoint répond. */ + method: HttpMethod; + /** Chemin relatif au `baseUrl` du service (ex. "/users"). Doit commencer par "/", sans query string. */ + path: string; +} + +/** + * Payload envoyé par un service pour s'enregistrer ou renouveler son + * enregistrement (heartbeat) — c'est la même opération d'upsert des deux côtés. + */ +export interface RegistrationPayload { + /** Nom unique et stable du service — sert aussi de préfixe de routage public. */ + serviceName: string; + /** URL de base vers laquelle l'agrégateur relaiera les requêtes matchées. */ + baseUrl: string; + /** Endpoints exposés par ce service. */ + endpoints: ServiceEndpoint[]; +} + +/** Réponse de l'agrégateur sur un enregistrement ou un heartbeat accepté. */ +export interface RegistrationAck { + /** Toujours `true` — les échecs utilisent un statut HTTP d'erreur, jamais ce corps. */ + ok: true; + /** Millisecondes avant expiration si aucun heartbeat suivant n'est reçu — informatif. */ + expiresInMs: number; +} + +/** Payload envoyé pour une désinscription explicite (best-effort, à l'arrêt d'un service). */ +export interface DeregistrationPayload { + /** Nom du service à désinscrire, tel que fourni lors de l'enregistrement. */ + serviceName: string; +} diff --git a/packages/server/service-discovery/tsconfig.json b/packages/server/service-discovery/tsconfig.json new file mode 100644 index 0000000..2309853 --- /dev/null +++ b/packages/server/service-discovery/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed2304f..ebef127 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,18 @@ importers: specifier: 'catalog:' version: 2.1.9(@types/node@22.20.3) + packages/server/service-discovery: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@22.20.3) + packages: '@babel/runtime@7.29.7': diff --git a/scripts/create-package.mjs b/scripts/create-package.mjs index a3e7618..c63a45b 100644 --- a/scripts/create-package.mjs +++ b/scripts/create-package.mjs @@ -123,11 +123,23 @@ rootTsconfig.references ??= []; rootTsconfig.references.push({ path: `./packages/${category}/${name}` }); writeFileSync(rootTsconfigPath, `${JSON.stringify(rootTsconfig, null, '\t')}\n`); -// Ajoute une ligne au registre des packages +// Ajoute une ligne dans le tableau du registre des packages (pas en fin de +// fichier : insérée juste après la dernière ligne existante du tableau, +// même si d'autres sections suivent plus bas dans le fichier). const packagesSpecPath = join(ROOT, 'spec', 'packages.md'); -const packagesSpec = readFileSync(packagesSpecPath, 'utf8'); -const newRow = `| \`${npmName}\` | ${category} | 0.0.1 | brouillon |\n`; -writeFileSync(packagesSpecPath, packagesSpec.trimEnd() + '\n' + newRow); +const packagesSpecLines = readFileSync(packagesSpecPath, 'utf8').split('\n'); +const newRow = `| \`${npmName}\` | ${category} | 0.0.1 | brouillon |`; +const separatorIndex = packagesSpecLines.findIndex((line) => line.startsWith('|---')); +if (separatorIndex === -1) { + packagesSpecLines.push(newRow); +} else { + let insertAt = separatorIndex + 1; + while (insertAt < packagesSpecLines.length && packagesSpecLines[insertAt].startsWith('|')) { + insertAt += 1; + } + packagesSpecLines.splice(insertAt, 0, newRow); +} +writeFileSync(packagesSpecPath, packagesSpecLines.join('\n')); console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`); diff --git a/spec/architecture.md b/spec/architecture.md index 4622fcf..6c3d3c8 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -34,6 +34,21 @@ Un nouveau package se crée avec `pnpm create-package /index.ts` vers `dist//index.js` en préservant l'arborescence. +- **Ré-exports nommés explicites obligatoires** dans chaque `index.ts` (jamais `export *`) : chaque point d'entrée doit ré-exporter les types du protocole partagé qu'il utilise dans sa surface publique, sinon la validation TypeDoc (bloquante en CI) échoue. `typedocOptions.entryPoints` dans le `package.json` du package doit lister les trois (ou plus) fichiers d'entrée. + +## Pattern de handler framework-agnostique (packages `server/`) + +Pour qu'un package `server/` reste utilisable quel que soit le framework HTTP de l'app hôte (le parc existant de l'auteur est déjà hétérogène : Express sur un projet, Fastify sur un autre), exposer la logique métier comme des handlers au format natif Node — `(req: IncomingMessage, res: ServerResponse) => void | Promise` — plutôt que de dépendre d'Express, Fastify ou une lib de routing. L'app hôte les monte elle-même : +- **Express** : compatible direct, `req`/`res` Express *sont* des `IncomingMessage`/`ServerResponse`. +- **Fastify** : nécessite `reply.hijack()` avant d'appeler le handler avec `request.raw`/`reply.raw`, pour indiquer à Fastify de ne plus toucher `reply` ensuite. +- **`node:http` brut** : correspond exactement à la signature, aucun adaptateur nécessaire. + ## Dépendances entre packages Les dépendances internes entre packages du monorepo utilisent le protocole `workspace:*` (résolu localement par pnpm, jamais via le registre distant). Le registre npm privé n'est utilisé que pour la publication et pour la consommation depuis des projets *externes* à ce monorepo. diff --git a/spec/packages.md b/spec/packages.md index 278e2ec..b8260f3 100644 --- a/spec/packages.md +++ b/spec/packages.md @@ -3,6 +3,8 @@ | Package | Catégorie | Version | Statut | |---|---|---|---| | `@lightweight-sdk/web-component` | client | 0.0.2 | migré depuis `lightweight-sdk-web-component`, non modifié | +| `@lightweight-sdk/service-registry` | common | 0.0.1 | brouillon | +| `@lightweight-sdk/service-discovery` | server | 0.0.1 | brouillon | ## Ajouter un package @@ -19,6 +21,7 @@ Le script scaffolde `package.json`, `tsconfig.json`, `src/index.ts` + un test, u - `engines.node >= 22` - `publishConfig.registry` pointant vers le registre npm privé (`https://git.dev.kyuno.fr/api/packages/kyuno/npm/`) - `typedocOptions.entryPoints` (sauf exception documentée, voir ci-dessous) +- `devDependencies` explicites pour `typescript`/`vitest`/`@types/node` (`catalog:`), même si déjà hoistées depuis la racine — un package sans dépendance propre peut se retrouver sans entrée dans `pnpm-lock.yaml`, cassant `pnpm install --frozen-lockfile` en CI (voir `spec/ci-cd.md`) ## Observations sur `@lightweight-sdk/web-component` (non traitées, laissées telles quelles) @@ -30,4 +33,3 @@ Le code source de ce package a été migré strictement à l'identique depuis l' - Le style de formatage d'origine (4 espaces, guillemets doubles) diffère de la config Biome du monorepo (tabulations, guillemets simples) — le formatter Biome est désactivé pour ce package via le même `overrides`. - `REQUEST_DOM_LINK_KEY` semble ne plus être utilisé nulle part dans le code (le mécanisme actif est le tableau `__linkedProperties`) — potentiellement mort, mais pas supprimé. - Quelques typos mineures ("atatched", "attriute") et un commentaire en français dans `LinkToDom` qui ne correspond plus exactement au comportement (`throw` au lieu d'un simple avertissement). -| `@lightweight-sdk/service-registry` | common | 0.0.1 | brouillon | diff --git a/tsconfig.json b/tsconfig.json index d752f19..731596f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,9 @@ }, { "path": "./packages/common/service-registry" + }, + { + "path": "./packages/server/service-discovery" } ] }