--- /dev/null
+// =========================================================
+// API GATEWAY - Wall-e-tte
+// =========================================================
+// Point d'entrée unique pour le frontend.
+//
+// USAGE : node gateway.js (depuis Wallette/gateway/)
+// PORT : process.env.GATEWAY_PORT || process.env.PORT || 3000
+//
+// ROUTING TABLE :
+// /api/price/* → PRICE_BASE_URL (défaut: http://127.0.0.1:3001)
+// /api/alerts/* → ALERTS_BASE_URL (défaut: http://127.0.0.1:3003)
+// /api/strategy/* → STRATEGY_BASE_URL (défaut: http://127.0.0.1:3002)
+//
+// PATH REWRITE :
+// Les services exposent leurs routes avec le préfixe complet /api/*
+// Le gateway transmet donc le path COMPLET tel quel.
+// 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';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+dotenv.config({ path: path.resolve(__dirname, '../server/.env') });
+
+// =========================================================
+// CONFIGURATION
+// =========================================================
+
+const GATEWAY_PORT = process.env.GATEWAY_PORT || process.env.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 PROXY_TIMEOUT_MS = 5000;
+
+// =========================================================
+// SERVEUR HTTP NATIF (Node >= 18, pas de dépendances lourdes)
+// =========================================================
+
+const server = http.createServer(async (req, res) => {
+ const url = req.url || '/';
+
+ // -------------------------------------------------------
+ // ROUTES INTERNES DU GATEWAY
+ // -------------------------------------------------------
+
+ // 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}/...`
+ }))
+ }
+ });
+ }
+
+ // 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 } });
+ }
+
+ // -------------------------------------------------------
+ // PROXY VERS LES SERVICES
+ // -------------------------------------------------------
+
+ // Trouver quel upstream correspond au path
+ const matchedPrefix = Object.keys(UPSTREAMS).find(prefix => url.startsWith(prefix));
+
+ if (!matchedPrefix) {
+ return sendJSON(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No route for ${url}` } });
+ }
+
+ const upstreamBase = UPSTREAMS[matchedPrefix];
+
+ // PATH REWRITE : on garde le path complet (les services exposent /api/...)
+ const targetUrl = upstreamBase + url;
+
+ // Lire le body pour les méthodes non-GET/HEAD
+ let body = null;
+ if (!['GET', 'HEAD'].includes(req.method)) {
+ body = await readBody(req);
+ }
+
+ console.log(`[gateway] ${req.method} ${url} → ${targetUrl}`);
+
+ try {
+ const result = await proxyFetch(req.method, targetUrl, body, PROXY_TIMEOUT_MS, req.headers);
+
+ // Retransmettre le statut et le body au client
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
+ res.end(result.body);
+
+ 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, {
+ ok: false,
+ error: {
+ code: 'UPSTREAM_DOWN',
+ message: `${serviceName} unavailable`
+ }
+ });
+ }
+});
+
+// =========================================================
+// HELPERS
+// =========================================================
+
+/**
+ * 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);
+ });
+}
+
+/**
+ * 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')
+ });
+ });
+ });
+
+ proxyReq.on('timeout', () => {
+ proxyReq.destroy();
+ reject(new Error(`Timeout after ${timeoutMs}ms`));
+ });
+
+ proxyReq.on('error', reject);
+
+ if (body && body.length > 0) {
+ proxyReq.write(body);
+ }
+
+ proxyReq.end();
+ });
+}
+
+/**
+ * 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)
+ });
+ res.end(body);
+}
+
+// =========================================================
+// DÉMARRAGE
+// =========================================================
+
+server.listen(GATEWAY_PORT, () => {
+ console.log(`
+╔══════════════════════════════════════════════════════════╗
+║ API GATEWAY - Wall-e-tte ║
+╠══════════════════════════════════════════════════════════╣
+║ Écoute sur : http://localhost:${GATEWAY_PORT} ║
+║ ║
+║ ROUTING : ║
+║ /api/price/* → ${UPSTREAMS['/api/price']} ║
+║ /api/alerts/* → ${UPSTREAMS['/api/alerts']} ║
+║ /api/strategy/* → ${UPSTREAMS['/api/strategy']} ║
+║ ║
+║ ENDPOINTS GATEWAY : ║
+║ GET /api/gateway/routes → table de routing ║
+║ GET /api/gateway/health → état des services ║
+╚══════════════════════════════════════════════════════════╝`);
+});
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "axios": "^1.13.5",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"node": ">= 6.0.0"
}
},
+ "node_modules/axios": {
+ "version": "1.13.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
+ "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"ms": "2.0.0"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"node": ">= 0.8"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",