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>
85 lines
3.1 KiB
TypeScript
85 lines
3.1 KiB
TypeScript
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);
|
|
}
|