Files
portal/render-service/server.mjs
T
Дмитрий de544c25b0 feat(autopodbor): рендер-сервис на виртуалке — Node+Playwright (фаза 6)
Рисовалка топологии Б (§5.6): портал зовёт по HTTP как xf4, она рисует и
отдаёт данные. Ни базы, ни Redis, ни денег. Смоук на живом 2ГИС: листинг 12
фирм, карточка 1 — чистый initialState.

- browser.mjs — постоянный браузер + маскировка + контекст-на-страницу +
  museum double-tap для поиска 2ГИС + отсечка картинок/шрифтов/медиа.
- proxy-pool.mjs — round-robin пул прокси из env PROXIES (резидентные/мобильные RU).
- server.mjs — POST /render {url,type}→{data}, auth X-Render-Key, семафор
  MAX_CONCURRENCY, /health.
- package.json, README.md, smoke.mjs, .gitignore (node_modules не в гит).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:00:48 +03:00

67 lines
2.9 KiB
JavaScript

// HTTP рендер-сервис на виртуалке (топология Б, §5.6). POST /render {url,type} → {data}.
// Портал (SelfRenderClient) зовёт как xf4. Ни базы, ни Redis, ни денег — только рисует.
// Секрет портал↔виртуалка — заголовок X-Render-Key (env RENDER_KEY). Параллельность контекстов —
// env MAX_CONCURRENCY (по памяти виртуалки, §9). Прокси — env PROXIES. Всё из окружения, не из гита.
import http from 'node:http';
import { render, closeBrowser } from './browser.mjs';
import { createProxyPool } from './proxy-pool.mjs';
const PORT = Number(process.env.PORT || 8790);
const KEY = String(process.env.RENDER_KEY || '');
const MAX = Math.max(1, Number(process.env.MAX_CONCURRENCY || 4));
const proxies = createProxyPool(process.env.PROXIES);
// Семафор: не больше MAX браузер-контекстов одновременно (память). Лишние ждут очереди.
let active = 0;
const waiters = [];
function acquire() {
if (active < MAX) { active++; return Promise.resolve(); }
return new Promise((res) => waiters.push(res));
}
function release() {
active--;
const next = waiters.shift();
if (next) { active++; next(); }
}
function readBody(req) {
return new Promise((resolve) => {
let buf = '';
req.on('data', (c) => { buf += c; if (buf.length > 1_000_000) req.destroy(); });
req.on('end', () => resolve(buf));
});
}
const server = http.createServer(async (req, res) => {
const json = (code, obj) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
if (req.method === 'GET' && req.url === '/health') return json(200, { ok: true, active, max: MAX, proxies: proxies.size });
if (req.method !== 'POST' || req.url !== '/render') return json(404, { error: 'not found' });
if (KEY === '' || req.headers['x-render-key'] !== KEY) return json(401, { error: 'unauthorized' });
let payload;
try { payload = JSON.parse(await readBody(req)); } catch { return json(400, { error: 'bad json' }); }
const url = String(payload?.url || '');
const type = payload?.type === 'initialState' ? 'initialState' : 'html';
if (!/^https?:\/\//i.test(url)) return json(400, { error: 'bad url' });
await acquire();
try {
const data = await render(url, type, proxies.next());
json(200, { data });
} catch (e) {
json(200, { data: '', error: String(e).slice(0, 200) }); // не роняем поток портала — пустые данные
} finally {
release();
}
});
server.listen(PORT, () => console.log(`[render-service] :${PORT} max=${MAX} proxies=${proxies.size}`));
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, async () => { await closeBrowser(); server.close(() => process.exit(0)); });
}