Files
tf_docs/server.js
T

156 lines
4.4 KiB
JavaScript

'use strict';
const http = require('http');
const https = require('https');
const PORT = Number(process.env.PORT) || 3000;
const PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS) || 30000;
const VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL;
if (!VM_DOCS_BASE_URL) {
throw new Error('VM_DOCS_BASE_URL environment variable is required');
}
const UPSTREAM = new URL(VM_DOCS_BASE_URL);
if (UPSTREAM.protocol !== 'http:' && UPSTREAM.protocol !== 'https:') {
throw new Error('VM_DOCS_BASE_URL must start with http:// or https://');
}
const UPSTREAM_MODULE = UPSTREAM.protocol === 'https:' ? https : http;
const UPSTREAM_BASE_PATH = UPSTREAM.pathname.replace(/\/+$/, '');
const UPSTREAM_PORT = UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80);
const STAND_RE = /^[A-Za-z0-9._-]+$/;
const FORWARD_HEADERS = ['content-type', 'content-length', 'cache-control', 'content-encoding', 'location'];
function respond(res, status, body) {
if (!res.headersSent) {
res.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
}
res.end(body);
}
function proxyRequest(req, res, targetPath) {
const options = {
protocol: UPSTREAM.protocol,
hostname: UPSTREAM.hostname,
port: UPSTREAM_PORT,
method: req.method,
path: targetPath,
timeout: PROXY_TIMEOUT_MS,
};
const upstreamReq = UPSTREAM_MODULE.request(options, (upstreamRes) => {
const headers = {};
for (const name of FORWARD_HEADERS) {
if (upstreamRes.headers[name] !== undefined) {
headers[name] = upstreamRes.headers[name];
}
}
res.writeHead(upstreamRes.statusCode || 502, headers);
upstreamRes.pipe(res);
});
upstreamReq.on('timeout', () => {
upstreamReq.destroy(new Error('Upstream timeout'));
});
upstreamReq.on('error', () => {
if (res.headersSent) {
res.destroy();
} else {
respond(res, 502, 'Bad Gateway');
}
});
req.on('aborted', () => upstreamReq.destroy());
upstreamReq.end();
}
// AI-ассистент: лениво грузим, не роняем процесс при ошибках загрузки.
let assistShared = null;
let assistHandler = null;
try {
const orchestrator = require('./orchestrator.js');
assistHandler = orchestrator.handleAssist;
assistShared = orchestrator.createSharedState();
} catch (_) {
assistHandler = null;
assistShared = null;
}
const server = http.createServer((req, res) => {
// POST /<stand>/_api/assist — ветка AI-ассистента (до проверки 405).
if (req.method === 'POST') {
const path = (req.url || '/').split('?')[0];
const match = path.match(/^\/([A-Za-z0-9._-]+)\/_api\/assist$/);
if (match && assistHandler && assistShared) {
const stand = match[1];
assistHandler(req, res, { stand, vmDocsBaseUrl: VM_DOCS_BASE_URL, shared: assistShared })
.catch(() => {
try {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'internal_error', message: 'assist failed' }));
}
} catch (_) { }
});
return;
}
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
respond(res, 405, 'Method Not Allowed');
return;
}
const rawUrl = req.url || '/';
const qIndex = rawUrl.indexOf('?');
const rawPath = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
const rawQuery = qIndex === -1 ? '' : rawUrl.slice(qIndex);
if (rawPath === '/health') {
respond(res, 200, 'ok');
return;
}
let decoded;
try {
decoded = decodeURIComponent(rawPath);
} catch (_) {
respond(res, 400, 'Bad Request');
return;
}
if (decoded.split('/').some((seg) => seg === '..')) {
respond(res, 400, 'Bad Request');
return;
}
let targetPath;
if (decoded === '/') {
targetPath = UPSTREAM_BASE_PATH + '/' + rawQuery;
} else {
const stand = decoded.split('/').filter(Boolean)[0];
if (!stand || !STAND_RE.test(stand)) {
respond(res, 400, 'Bad Request');
return;
}
targetPath = UPSTREAM_BASE_PATH + rawPath + rawQuery;
}
proxyRequest(req, res, targetPath);
});
if (require.main === module) {
server.listen(PORT, () => {
console.log(`[docs-proxy] listening on port ${PORT}, upstream: ${VM_DOCS_BASE_URL}`);
});
}
module.exports = { server };