//
// ROUTING TABLE :
// /api/price/* → PRICE_BASE_URL (défaut: http://127.0.0.1:3001)
+// /api/pairs → PRICE_BASE_URL (exposition directe des paires)
// /api/alerts/* → ALERTS_BASE_URL (défaut: http://127.0.0.1:3003)
+// /api/wallets/* → WALLET_BASE_URL (défaut: http://127.0.0.1:3004)
// /api/strategy/* → STRATEGY_BASE_URL (défaut: http://127.0.0.1:3002)
+// /socket.io/* → ALERTS_BASE_URL (WebSocket proxy)
//
// PATH REWRITE :
-// Les services exposent leurs routes avec le préfixe complet /api/*
-// Le gateway transmet donc le path COMPLET tel quel.
+// Le path est transmis COMPLET tel quel aux services.
// Ex: GET /api/price/current → http://127.0.0.1:3001/api/price/current
// =========================================================
import dotenv from 'dotenv';
-import path from 'path';
-import { fileURLToPath } from 'url';
import http from 'http';
import https from 'https';
+import net from 'net';
+import tls from 'tls';
+import path from 'path';
+import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// CONFIGURATION
// =========================================================
-const GATEWAY_PORT = process.env.GATEWAY_PORT || process.env.PORT || 3000;
+const GATEWAY_PORT = Number(process.env.PORT || process.env.GATEWAY_PORT) || 3000;
-const UPSTREAMS = {
- '/api/price': process.env.PRICE_BASE_URL || 'http://127.0.0.1:3001',
- '/api/alerts': process.env.ALERTS_BASE_URL || 'http://127.0.0.1:3003',
- '/api/strategy': process.env.STRATEGY_BASE_URL || 'http://127.0.0.1:3002',
+const SERVICE_BASE_URLS = {
+ price: process.env.PRICE_BASE_URL || 'http://127.0.0.1:3001',
+ alerts: process.env.ALERTS_BASE_URL || 'http://127.0.0.1:3003',
+ strategy: process.env.STRATEGY_BASE_URL || 'http://127.0.0.1:3002',
+ wallet: process.env.WALLET_BASE_URL || 'http://127.0.0.1:3004',
};
+// Tri par longueur décroissante pour que les préfixes longs matchent en premier
+const ROUTES = [
+ // WebSocket Socket.IO — proxy TCP direct vers alerts
+ { prefix: '/socket.io', upstream: SERVICE_BASE_URLS.alerts, name: 'alerts-service', ws: true },
+
+ // API REST
+ { prefix: '/api/alerts', upstream: SERVICE_BASE_URLS.alerts, name: 'alerts-service' },
+ { prefix: '/api/wallets', upstream: SERVICE_BASE_URLS.wallet, name: 'wallet-service' },
+ { prefix: '/api/price', upstream: SERVICE_BASE_URLS.price, name: 'price-service' },
+ { prefix: '/api/pairs', upstream: SERVICE_BASE_URLS.price, name: 'price-service' },
+ { prefix: '/api/strategy', upstream: SERVICE_BASE_URLS.strategy, name: 'strategy-service' },
+].sort((a, b) => b.prefix.length - a.prefix.length);
+
const PROXY_TIMEOUT_MS = 5000;
// =========================================================
-// SERVEUR HTTP NATIF (Node >= 18, pas de dépendances lourdes)
+// HELPERS CORS
// =========================================================
-const server = http.createServer(async (req, res) => {
- const url = req.url || '/';
-
- // -------------------------------------------------------
- // ROUTES INTERNES DU GATEWAY
- // -------------------------------------------------------
+function applyCorsHeaders(headers) {
+ headers['access-control-allow-origin'] = '*';
+ headers['access-control-allow-methods'] = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
+ headers['access-control-allow-headers'] = 'Content-Type, Authorization';
+ headers['access-control-max-age'] = '86400';
+ return headers;
+}
- // GET /api/gateway/routes → table de routing (dev)
- if (req.method === 'GET' && url === '/api/gateway/routes') {
- return sendJSON(res, 200, {
- ok: true,
- data: {
- gateway_port: GATEWAY_PORT,
- path_rewrite: 'FULL PATH (path transmis tel quel aux services)',
- routes: Object.entries(UPSTREAMS).map(([prefix, upstream]) => ({
- prefix,
- upstream,
- example: `${prefix}/... → ${upstream}${prefix}/...`
- }))
- }
- });
- }
+function sendJSON(res, statusCode, payload) {
+ const body = JSON.stringify(payload);
+ const headers = applyCorsHeaders({
+ 'content-type': 'application/json; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ });
+ res.writeHead(statusCode, headers);
+ res.end(body);
+}
- // GET /api/gateway/health → ping des services upstream
- if (req.method === 'GET' && url === '/api/gateway/health') {
- const checks = await Promise.all(
- Object.entries(UPSTREAMS).map(async ([prefix, upstream]) => {
- const healthUrl = `${upstream}/health`;
- try {
- const result = await proxyFetch('GET', healthUrl, null, 2000);
- return { prefix, upstream, status: result.status, ok: result.status < 400 };
- } catch (e) {
- return { prefix, upstream, status: 'DOWN', ok: false, error: e.message };
- }
- })
- );
- const allOk = checks.every(c => c.ok);
- return sendJSON(res, allOk ? 200 : 207, { ok: allOk, data: { services: checks } });
- }
+// =========================================================
+// ROUTE MATCHING
+// =========================================================
- // -------------------------------------------------------
- // PROXY VERS LES SERVICES
- // -------------------------------------------------------
+function matchRoute(url = '') {
+ return ROUTES.find(r => url.startsWith(r.prefix)) || null;
+}
- // Trouver quel upstream correspond au path
- const matchedPrefix = Object.keys(UPSTREAMS).find(prefix => url.startsWith(prefix));
+function pickHttpLib(protocol) {
+ return protocol === 'https:' ? https : http;
+}
- if (!matchedPrefix) {
- return sendJSON(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No route for ${url}` } });
- }
+// Retire les hop-by-hop headers (RFC 7230) avant de transmettre
+function cleanHopByHop(headers) {
+ const hopByHop = [
+ 'connection', 'keep-alive', 'proxy-authenticate',
+ 'proxy-authorization', 'te', 'trailers',
+ 'transfer-encoding', 'upgrade', 'proxy-connection',
+ ];
+ for (const h of hopByHop) delete headers[h];
+}
- const upstreamBase = UPSTREAMS[matchedPrefix];
+// =========================================================
+// HEALTH CHECKS
+// =========================================================
- // PATH REWRITE : on garde le path complet (les services exposent /api/...)
- const targetUrl = upstreamBase + url;
+function checkHealth(serviceName, baseUrl, timeoutMs = 1000) {
+ return new Promise(resolve => {
+ const upstream = new URL(baseUrl);
+ const lib = pickHttpLib(upstream.protocol);
+
+ const r = lib.request({
+ hostname: upstream.hostname,
+ port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
+ method: 'GET',
+ path: '/health',
+ headers: { host: upstream.host },
+ timeout: timeoutMs,
+ }, upRes => {
+ upRes.resume();
+ resolve({
+ service: serviceName,
+ url: baseUrl,
+ ok: (upRes.statusCode || 500) < 400,
+ status: upRes.statusCode,
+ });
+ });
- // Lire le body pour les méthodes non-GET/HEAD
- let body = null;
- if (!['GET', 'HEAD'].includes(req.method)) {
- body = await readBody(req);
- }
+ r.on('timeout', () => {
+ r.destroy();
+ resolve({ service: serviceName, url: baseUrl, ok: false, status: 'TIMEOUT' });
+ });
+ r.on('error', err => {
+ resolve({ service: serviceName, url: baseUrl, ok: false, status: 'DOWN', error: err.message });
+ });
+ r.end();
+ });
+}
- console.log(`[gateway] ${req.method} ${url} → ${targetUrl}`);
+// =========================================================
+// HTTP PROXY
+// =========================================================
- try {
- const result = await proxyFetch(req.method, targetUrl, body, PROXY_TIMEOUT_MS, req.headers);
+function proxyHttpRequest(req, res, route) {
+ const incomingUrl = req.url || '/';
+ const upstream = new URL(route.upstream);
+ const lib = pickHttpLib(upstream.protocol);
+ const startedAt = Date.now();
+
+ const headers = { ...req.headers };
+ headers.host = upstream.host;
+ headers['x-forwarded-host'] = req.headers.host || '';
+ headers['x-forwarded-proto'] = 'http';
+ headers['x-forwarded-for'] = req.socket?.remoteAddress || '';
+ cleanHopByHop(headers);
+
+ const upstreamReq = lib.request({
+ hostname: upstream.hostname,
+ port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
+ method: req.method,
+ path: incomingUrl,
+ headers,
+ timeout: PROXY_TIMEOUT_MS,
+ }, upRes => {
+ const outHeaders = applyCorsHeaders({ ...upRes.headers });
+ outHeaders['x-gateway-upstream'] = route.name;
+ res.writeHead(upRes.statusCode || 500, outHeaders);
+ upRes.pipe(res);
+
+ res.on('finish', () => {
+ const ms = Date.now() - startedAt;
+ console.log(`[gateway] ${req.method} ${incomingUrl} → ${route.upstream}${incomingUrl} (${upRes.statusCode}) ${ms}ms`);
+ });
+ });
- // Retransmettre le statut et le body au client
- res.writeHead(result.status, { 'Content-Type': 'application/json' });
- res.end(result.body);
+ upstreamReq.on('timeout', () => upstreamReq.destroy(new Error(`Timeout after ${PROXY_TIMEOUT_MS}ms`)));
- console.log(`[gateway] ${req.method} ${url} → ${targetUrl} (${result.status})`);
- } catch (err) {
- // Service upstream injoignable → 502
- const serviceName = matchedPrefix.replace('/api/', '') + '-service';
- console.error(`[gateway] UPSTREAM_DOWN: ${targetUrl} — ${err.message}`);
- return sendJSON(res, 502, {
+ upstreamReq.on('error', err => {
+ if (res.headersSent) { try { res.end(); } catch {} return; }
+ console.error(`[gateway] UPSTREAM_DOWN ${route.name}: ${err.message}`);
+ sendJSON(res, 502, {
ok: false,
- error: {
- code: 'UPSTREAM_DOWN',
- message: `${serviceName} unavailable`
- }
+ error: { code: 'UPSTREAM_DOWN', message: `${route.name} unavailable` },
});
- }
-});
+ });
+
+ req.on('aborted', () => upstreamReq.destroy());
+ res.on('close', () => upstreamReq.destroy());
+ req.pipe(upstreamReq);
+}
// =========================================================
-// HELPERS
+// WEBSOCKET PROXY (Socket.IO via /socket.io/*)
// =========================================================
-/**
- * Lit le body d'une requête entrante et retourne un Buffer
- */
-function readBody(req) {
- return new Promise((resolve, reject) => {
- const chunks = [];
- req.on('data', chunk => chunks.push(chunk));
- req.on('end', () => resolve(Buffer.concat(chunks)));
- req.on('error', reject);
- });
-}
+function proxyWebSocketUpgrade(req, clientSocket, head) {
+ const incomingUrl = req.url || '/';
-/**
- * Envoie une requête HTTP vers un upstream
- * @param {string} method
- * @param {string} url
- * @param {Buffer|null} body
- * @param {number} timeoutMs
- * @param {object} [incomingHeaders]
- * @returns {Promise<{status: number, body: string}>}
- */
-function proxyFetch(method, url, body, timeoutMs = PROXY_TIMEOUT_MS, incomingHeaders = {}) {
- return new Promise((resolve, reject) => {
- const parsed = new URL(url);
- const isHttps = parsed.protocol === 'https:';
- const lib = isHttps ? https : http;
-
- // Headers à transmettre (on filtre les headers problématiques)
- const headers = {
- 'Content-Type': 'application/json',
- };
- if (incomingHeaders['authorization']) headers['Authorization'] = incomingHeaders['authorization'];
- if (body && body.length > 0) headers['Content-Length'] = body.length;
-
- const options = {
- hostname: parsed.hostname,
- port: parsed.port || (isHttps ? 443 : 80),
- path: parsed.pathname + parsed.search,
- method,
- headers,
- timeout: timeoutMs,
- };
-
- const proxyReq = lib.request(options, (proxyRes) => {
- const chunks = [];
- proxyRes.on('data', chunk => chunks.push(chunk));
- proxyRes.on('end', () => {
- resolve({
- status: proxyRes.statusCode,
- body: Buffer.concat(chunks).toString('utf8')
- });
- });
- });
+ if (!incomingUrl.startsWith('/socket.io')) {
+ clientSocket.destroy();
+ return;
+ }
- proxyReq.on('timeout', () => {
- proxyReq.destroy();
- reject(new Error(`Timeout after ${timeoutMs}ms`));
- });
+ const upstream = new URL(SERVICE_BASE_URLS.alerts);
+ const port = Number(upstream.port) || (upstream.protocol === 'https:' ? 443 : 80);
+ const connect = upstream.protocol === 'https:' ? tls.connect : net.connect;
+
+ const upstreamSocket = connect({ host: upstream.hostname, port });
- proxyReq.on('error', reject);
+ upstreamSocket.on('connect', () => {
+ const headers = { ...req.headers, host: upstream.host };
+ delete headers['proxy-connection'];
- if (body && body.length > 0) {
- proxyReq.write(body);
+ let headerLines = '';
+ for (const [key, value] of Object.entries(headers)) {
+ if (value === undefined) continue;
+ const v = Array.isArray(value) ? value.join(',') : String(value);
+ headerLines += `${key}: ${v}\r\n`;
}
- proxyReq.end();
+ upstreamSocket.write(`${req.method || 'GET'} ${incomingUrl} HTTP/1.1\r\n` + headerLines + '\r\n');
+ if (head && head.length > 0) upstreamSocket.write(head);
+
+ upstreamSocket.pipe(clientSocket);
+ clientSocket.pipe(upstreamSocket);
+ clientSocket.resume();
+
+ console.log(`[gateway][ws] UPGRADE ${incomingUrl} → ${upstream.origin}${incomingUrl}`);
});
-}
-/**
- * Envoie une réponse JSON
- */
-function sendJSON(res, status, data) {
- const body = JSON.stringify(data);
- res.writeHead(status, {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(body)
+ upstreamSocket.on('error', err => {
+ console.error(`[gateway][ws] UPSTREAM_DOWN alerts-service: ${err.message}`);
+ try { clientSocket.end(); } catch {}
});
- res.end(body);
+
+ clientSocket.on('error', () => { try { upstreamSocket.end(); } catch {} });
}
+// =========================================================
+// SERVEUR HTTP
+// =========================================================
+
+const server = http.createServer(async (req, res) => {
+ const url = req.url || '/';
+
+ // Preflight CORS
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, applyCorsHeaders({}));
+ res.end();
+ return;
+ }
+
+ // Health gateway
+ if (req.method === 'GET' && url === '/health') {
+ return sendJSON(res, 200, { ok: true, data: { service: 'gateway', port: GATEWAY_PORT } });
+ }
+
+ // Table de routing (dev)
+ if (req.method === 'GET' && url === '/api/gateway/routes') {
+ return sendJSON(res, 200, {
+ ok: true,
+ data: {
+ gateway_port: GATEWAY_PORT,
+ path_rewrite: 'FULL PATH (path transmis tel quel aux services)',
+ routes: ROUTES.map(r => ({
+ prefix: r.prefix,
+ upstream: r.upstream,
+ service: r.name,
+ websocket: Boolean(r.ws),
+ })),
+ },
+ });
+ }
+
+ // Health de tous les services
+ if (req.method === 'GET' && url === '/api/gateway/health') {
+ const checks = await Promise.all([
+ checkHealth('price-service', SERVICE_BASE_URLS.price),
+ checkHealth('alerts-service', SERVICE_BASE_URLS.alerts),
+ checkHealth('wallet-service', SERVICE_BASE_URLS.wallet),
+ checkHealth('strategy-service', SERVICE_BASE_URLS.strategy),
+ ]);
+
+ const required = ['price-service', 'alerts-service', 'wallet-service'];
+ const requiredOk = checks.filter(c => required.includes(c.service)).every(c => c.ok);
+
+ return sendJSON(res, requiredOk ? 200 : 207, {
+ ok: requiredOk,
+ data: {
+ gateway: { ok: true, port: GATEWAY_PORT },
+ services: checks.reduce((acc, c) => { acc[c.service] = c; return acc; }, {}),
+ },
+ });
+ }
+
+ // Proxy vers les services
+ const route = matchRoute(url);
+ if (!route) {
+ return sendJSON(res, 404, {
+ ok: false,
+ error: { code: 'NOT_FOUND', message: `No route for ${url}` },
+ });
+ }
+
+ return proxyHttpRequest(req, res, route);
+});
+
+// Proxy WebSocket (Socket.IO upgrade)
+server.on('upgrade', (req, socket, head) => {
+ proxyWebSocketUpgrade(req, socket, head);
+});
+
// =========================================================
// DÉMARRAGE
// =========================================================
╔══════════════════════════════════════════════════════════╗
║ API GATEWAY - Wall-e-tte ║
╠══════════════════════════════════════════════════════════╣
-║ Écoute sur : http://localhost:${GATEWAY_PORT} ║
+║ Écoute sur : http://localhost:${GATEWAY_PORT} ║
║ ║
║ ROUTING : ║
-║ /api/price/* → ${UPSTREAMS['/api/price']} ║
-║ /api/alerts/* → ${UPSTREAMS['/api/alerts']} ║
-║ /api/strategy/* → ${UPSTREAMS['/api/strategy']} ║
+║ /api/price/* → ${SERVICE_BASE_URLS.price} ║
+║ /api/pairs → ${SERVICE_BASE_URLS.price} ║
+║ /api/alerts/* → ${SERVICE_BASE_URLS.alerts} ║
+║ /socket.io/* → ${SERVICE_BASE_URLS.alerts} (WS+HTTP) ║
+║ /api/wallets/* → ${SERVICE_BASE_URLS.wallet} ║
+║ /api/strategy/* → ${SERVICE_BASE_URLS.strategy} ║
║ ║
║ ENDPOINTS GATEWAY : ║
+║ GET /health → gateway ok ║
║ GET /api/gateway/routes → table de routing ║
║ GET /api/gateway/health → état des services ║
╚══════════════════════════════════════════════════════════╝`);