de544c25b0
Рисовалка топологии Б (§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>
67 lines
2.9 KiB
JavaScript
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)); });
|
|
}
|