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); }