-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
460 lines (409 loc) · 19.1 KB
/
Copy pathserver.js
File metadata and controls
460 lines (409 loc) · 19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
// puppeteer-core é ESM: require() dele só funciona a partir do Node 22.12.
// Carregar por import() dinâmico mantém o servidor rodando em qualquer versão.
const puppeteerPromise = import('puppeteer-core').then(m => m.default);
const fs = require('fs');
const path = require('path');
const { assertPublicUrl, HARDENING_ARGS } = require('./lib/safe-url');
const site = require('./lib/site-content');
const { profileFor, applyDeviceProfile, emulateStandalone } = require('./lib/device-profiles');
const app = express();
const PORT = process.env.PORT || 5177;
// Tipos de quadro no protocolo binário (primeiro byte da mensagem)
const FRAME_LIVE = 1; // screencast 1x — rápido, usado enquanto o usuário interage
const FRAME_SHARP = 2; // screenshot em alta resolução — usado quando a tela está parada
const FRAME_SHOT = 3; // PNG da captura pedida pelo botão de download
const IDLE_AFTER_MS = 300; // tempo sem interação até começar a mandar quadros nítidos
const SHARP_EVERY_MS = 700; // intervalo entre quadros nítidos enquanto está parado
const SHARP_TIMEOUT_MS = 3000; // desiste de uma captura nítida travada
const MAX_BUFFERED_BYTES = 2 * 1024 * 1024; // se o socket entupir, pula quadros
// Reusa um Chrome/Chromium já instalado em vez de baixar outro: no contêiner
// vem do pacote do sistema, no Windows do Chrome/Edge do próprio usuário.
const CANDIDATE_BROWSERS = [
process.env.PUPPETEER_EXECUTABLE_PATH,
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
].filter(Boolean);
function findBrowser() {
for (const candidate of CANDIDATE_BROWSERS) {
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
const executablePath = findBrowser();
if (!executablePath) {
console.error('Nenhum navegador Chrome ou Edge encontrado nos caminhos padrão.');
process.exit(1);
}
let browserPromise = null;
// Descarta o navegador em cache pra que a próxima sessão suba um novo. Sem
// isto, um Chrome que morre ou trava derruba TODAS as sessões seguintes: o
// promise fica cacheado e o servidor só volta a funcionar reiniciando.
function dropBrowser(motivo) {
if (!browserPromise) return;
console.error(`[browser] descartado (${motivo}) — a próxima sessão sobe um novo`);
const dying = browserPromise;
browserPromise = null;
dying.then(b => b.close().catch(() => {})).catch(() => {});
}
function getBrowser() {
if (!browserPromise) {
browserPromise = puppeteerPromise.then(puppeteer => puppeteer.launch({
executablePath,
headless: true,
args: HARDENING_ARGS,
}));
browserPromise.catch(() => { browserPromise = null; });
// O Chrome pode cair sozinho (OOM no contêiner, crash). Quando isso
// acontece, o puppeteer emite 'disconnected' — é o gancho pra não seguir
// entregando um navegador morto.
browserPromise.then(b => b.on('disconnected', () => {
if (browserPromise) { browserPromise = null; console.error('[browser] desconectado'); }
})).catch(() => {});
}
return browserPromise;
}
// Abre uma aba já lidando com o caso do navegador ruim. A primeira tentativa
// depois de um crash costuma pegar um Chrome que subiu no meio da limpeza do
// anterior e não responde; descartar e repetir uma vez resolve sozinho, sem o
// visitante ver erro nenhum.
async function openPage(label) {
for (let tentativa = 1; tentativa <= 2; tentativa++) {
try {
const browser = await withTimeout(getBrowser(), 'abrir navegador', BROWSER_LAUNCH_TIMEOUT_MS);
return await withTimeout(browser.newPage(), 'abrir aba');
} catch (err) {
dropBrowser(`${label}: ${err.message}`);
if (tentativa === 2) throw err;
console.log('[browser] nova tentativa apos falha');
}
}
}
// Um celular de verdade tem GPS, e apps que dependem dele (marcação de ponto,
// entrega, mapas) travam logo na abertura se a permissão for negada — que é o
// padrão do Chrome headless. Conceder sem posição não resolve: getCurrentPosition
// fica pendurado até dar erro. Por isso vai junto uma coordenada, ajustável por
// GEO_LAT/GEO_LON pra quem precisa testar geocerca de um endereço específico.
const GEO = {
latitude: Number(process.env.GEO_LAT ?? -23.5505),
longitude: Number(process.env.GEO_LON ?? -46.6333),
accuracy: Number(process.env.GEO_ACCURACY ?? 30),
};
async function allowGeolocation(page, rawUrl) {
try {
const origin = new URL(rawUrl).origin;
await page.browserContext().overridePermissions(origin, ['geolocation']);
await page.setGeolocation(GEO);
} catch (err) {
// Sem GPS o site ainda abre; não vale derrubar a sessão por causa disso.
console.error('[geo] não foi possível liberar a localização:', err.message);
}
}
// Um Chrome sobrecarregado para de responder sem desconectar: os awaits ficam
// pendurados pra sempre e o cliente vê só o spinner girando. O prazo aqui
// transforma isso num erro visível e força a troca do navegador.
const BROWSER_OP_TIMEOUT_MS = 15000;
// Subir o Chrome do zero é bem mais lento que as demais operações, ainda mais
// num contêiner apertado ou numa máquina carregada, então tem prazo próprio.
const BROWSER_LAUNCH_TIMEOUT_MS = 45000;
function withTimeout(promise, label, ms = BROWSER_OP_TIMEOUT_MS) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`tempo esgotado: ${label}`)), ms);
}),
]).finally(() => clearTimeout(timer));
}
// ---------- Páginas, SEO e leitura por IA ----------
// O conteúdo do site fica em HTML de verdade, sem depender de JavaScript, para
// que buscadores e rastreadores de IA consigam ler. A geração é compartilhada
// com a build estática da Vercel (lib/site-content.js), para as duas hospedagens
// produzirem exatamente as mesmas páginas.
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const ADSENSE_CLIENT = (process.env.ADSENSE_CLIENT || '').trim();
const pageOpts = {
publicUrl: PUBLIC_URL,
adsenseClient: ADSENSE_CLIENT,
slot1: process.env.ADSENSE_SLOT_1 || '',
slot2: process.env.ADSENSE_SLOT_2 || '',
liveEngine: '', // mesmo servidor: aqui a navegação ao vivo está disponível
};
app.get('/', (_req, res) => res.type('html').send(site.renderPage('index.html', pageOpts)));
app.get('/privacidade', (_req, res) => res.type('html').send(site.renderPage('privacidade.html', pageOpts)));
// index: false para que a rota acima trate a home em vez do arquivo cru
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
app.get('/robots.txt', (_req, res) => res.type('text/plain').send(site.robotsTxt(PUBLIC_URL)));
app.get('/llms.txt', (_req, res) => res.type('text/plain').send(site.llmsTxt(PUBLIC_URL)));
app.get('/sitemap.xml', (_req, res) => res.type('application/xml').send(site.sitemapXml(PUBLIC_URL)));
app.get('/ads.txt', (_req, res) => {
const body = site.adsTxt(ADSENSE_CLIENT);
return body ? res.type('text/plain').send(body) : res.status(404).type('text/plain').send('');
});
// Mesmo endpoint da função serverless (api/screenshot.js), para que os dois
// ambientes se comportem igual e o modo de captura estática possa ser testado
// aqui também.
app.get('/api/screenshot', async (req, res) => {
const rawUrl = req.query.url;
const w = Math.round(Number(req.query.width));
const h = Math.round(Number(req.query.height));
const scale = Math.min(Number(req.query.dpr) || 2, 3);
const isMobile = req.query.mobile === '1';
const profile = profileFor(req.query.os);
const standalone = req.query.standalone === '1';
if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) {
return res.status(400).json({ error: 'URL inválida. Use http:// ou https://' });
}
if (!w || !h || w < 100 || h < 100 || w > 3000 || h > 3000) {
return res.status(400).json({ error: 'Dimensões de viewport inválidas.' });
}
try {
await assertPublicUrl(rawUrl);
} catch (err) {
return res.status(400).json({ error: err.message });
}
let page;
try {
page = await openPage('captura');
await page.setViewport({ width: w, height: h, deviceScaleFactor: scale, isMobile, hasTouch: isMobile });
const shotClient = await page.createCDPSession();
await applyDeviceProfile(shotClient, profile);
if (standalone) await emulateStandalone(page);
await allowGeolocation(page, rawUrl);
await page.goto(rawUrl, { waitUntil: 'networkidle2', timeout: 20000 });
const buffer = await page.screenshot({ type: 'png' });
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'no-store');
res.send(buffer);
} catch (err) {
const timedOut = /timeout|tempo esgotado/i.test(err.message || '');
if (/tempo esgotado|Target closed|Session closed|Connection closed|Protocol error/i.test(err.message || '')) {
dropBrowser(err.message);
}
res.status(502).json({
error: timedOut
? 'O site demorou demais para responder.'
: 'Não foi possível carregar essa URL (o site pode bloquear acesso automatizado ou não existir).',
});
} finally {
if (page) await page.close().catch(() => {});
}
});
app.get('/health', (_req, res) => res.json({ ok: true }));
const server = http.createServer(app);
// Cada sessão mantém uma aba de Chrome viva; sem teto, visitas simultâneas
// esgotariam a memória do contêiner.
const MAX_SESSIONS = Number(process.env.MAX_SESSIONS || 6);
let activeSessions = 0;
// ---------- Sessão remota ao vivo ----------
// O site roda de verdade num Chrome headless com a emulação do dispositivo
// escolhido (user-agent mobile, toque, viewport), então PWAs reconhecem
// corretamente que estão num celular. Como nada é embutido no navegador do
// usuário, também funciona em sites que bloqueiam iframe.
//
// A transmissão é HÍBRIDA, porque nenhum método sozinho serve:
// - Page.startScreencast é rápido (~15fps) mas o Chrome headless ignora o
// deviceScaleFactor e sempre entrega 1x — fica borrado numa tela HiDPI.
// - page.screenshot respeita o DPR (nítido) mas leva ~105ms por quadro,
// ou seja no máximo ~10fps: navegar com ele fica lento demais.
// Então: enquanto o usuário interage, manda screencast (fluido); assim que ele
// para, manda quadros em alta resolução (nítido). O melhor dos dois.
const wss = new WebSocketServer({ server, path: '/ws/session' });
wss.on('connection', async (ws, req) => {
const params = new URL(req.url, 'http://localhost').searchParams;
const rawUrl = params.get('url');
const width = Math.round(Number(params.get('width')));
const height = Math.round(Number(params.get('height')));
const dpr = Math.min(Number(params.get('dpr')) || 2, 3);
const mobile = params.get('mobile') === '1';
const profile = profileFor(params.get('os'));
const standalone = params.get('standalone') === '1';
if (!rawUrl || !/^https?:\/\//i.test(rawUrl) || !width || !height || width < 100 || height < 100) {
ws.send(JSON.stringify({ t: 'error', message: 'Parâmetros inválidos.' }));
ws.close();
return;
}
try {
await assertPublicUrl(rawUrl);
} catch (err) {
ws.send(JSON.stringify({ t: 'error', message: err.message }));
ws.close();
return;
}
if (activeSessions >= MAX_SESSIONS) {
console.log(`[sessao] RECUSADA - teto de ${MAX_SESSIONS} atingido`);
ws.send(JSON.stringify({ t: 'error', message: 'O servidor está com todas as sessões ocupadas. Tente de novo em instantes.' }));
ws.close();
return;
}
activeSessions++;
let page = null;
let client = null;
let closed = false;
let sharpTimer = null;
// Começa como se o usuário tivesse acabado de interagir: assim o primeiro
// desenho da tela vem pelo caminho rápido (importante no cold start, quando
// o Chrome ainda está subindo) e só depois assume o modo nítido.
let lastInputAt = Date.now();
let lastSharpAt = 0; // último quadro nítido enviado
let shotInFlight = false;
let screencastOn = false;
const cleanup = async () => {
if (closed) return;
closed = true;
activeSessions--;
if (sharpTimer) clearInterval(sharpTimer);
try { if (page) await page.close(); } catch {}
};
// Registrado já, antes de qualquer await da inicialização: se o visitante
// fechar a aba enquanto o navegador ainda está subindo, a limpeza precisa
// acontecer mesmo assim — senão a vaga fica presa e, depois de algumas
// visitas, o servidor passa a recusar todo mundo.
ws.on('close', cleanup);
ws.on('error', cleanup);
const sendFrame = (type, buffer) => {
if (closed || ws.readyState !== ws.OPEN) return;
if (type !== FRAME_SHOT && ws.bufferedAmount > MAX_BUFFERED_BYTES) return; // socket entupido: descarta
ws.send(Buffer.concat([Buffer.from([type]), buffer]));
};
// O screencast só roda enquanto o usuário está interagindo. Parado, ele é
// desligado e a tela passa a ser enviada só em alta resolução — senão uma
// página que se atualiza sozinha (um relógio, por exemplo) ficaria mandando
// quadros 1x sem parar e apagando os nítidos, deixando tudo borrado.
const startScreencast = async () => {
if (screencastOn || closed) return;
screencastOn = true;
try {
await client.send('Page.startScreencast', {
format: 'jpeg',
quality: 70, // baixa de propósito: é o modo "em movimento", o nítido vem depois
maxWidth: width,
maxHeight: height,
everyNthFrame: 1,
});
} catch { screencastOn = false; }
};
const stopScreencast = async () => {
if (!screencastOn || closed) return;
screencastOn = false;
try { await client.send('Page.stopScreencast'); } catch {}
};
// Captura em alta resolução, com prazo — se travar (navegação em andamento,
// por exemplo) a gente desiste dela em vez de congelar a transmissão.
const takeSharp = async (type = FRAME_SHARP, format = 'jpeg') => {
if (closed) return;
// Quadro nítido de rotina: se já tem uma captura rolando, pula essa vez.
// Já a captura do botão de download não pode ser descartada — espera a vez.
if (type === FRAME_SHARP && shotInFlight) return;
while (shotInFlight && !closed) await new Promise(r => setTimeout(r, 25));
if (closed) return;
shotInFlight = true;
const opts = format === 'png' ? { type: 'png' } : { type: 'jpeg', quality: 92 };
const shot = page.screenshot(opts).catch(() => null);
const buffer = await Promise.race([
shot,
new Promise(resolve => setTimeout(() => resolve(null), SHARP_TIMEOUT_MS)),
]);
shotInFlight = false;
if (buffer) {
lastSharpAt = Date.now();
sendFrame(type, buffer);
} else if (type === FRAME_SHOT && ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ t: 'capture-error', message: 'Falha ao gerar a captura para download.' }));
}
};
try {
page = await openPage('sessao ao vivo');
await withTimeout(page.setViewport({ width, height, deviceScaleFactor: dpr, isMobile: mobile, hasTouch: mobile }), 'viewport');
client = await withTimeout(page.createCDPSession(), 'sessao CDP');
await withTimeout(applyDeviceProfile(client, profile), 'perfil do aparelho');
if (standalone) await withTimeout(emulateStandalone(page), 'modo app instalado');
await withTimeout(allowGeolocation(page, rawUrl), 'liberar localizacao');
client.on('Page.screencastFrame', async ({ data, sessionId }) => {
sendFrame(FRAME_LIVE, Buffer.from(data, 'base64'));
try { await client.send('Page.screencastFrameAck', { sessionId }); } catch {}
});
await startScreencast();
// Enquanto o usuário não está mexendo, vai reenviando a tela em alta
// resolução. Isso também mantém nítida uma tela que se atualiza sozinha
// (um relógio rodando, por exemplo), sem atrapalhar a navegação.
sharpTimer = setInterval(() => {
if (closed || shotInFlight) return;
const now = Date.now();
if (now - lastInputAt < IDLE_AFTER_MS) return;
if (screencastOn) stopScreencast();
if (now - lastSharpAt < SHARP_EVERY_MS) return;
takeSharp(FRAME_SHARP, 'png');
}, 120);
ws.send(JSON.stringify({ t: 'ready' }));
page.goto(rawUrl, { waitUntil: 'domcontentloaded', timeout: 20000 })
.catch(() => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ t: 'error', message: 'Não foi possível abrir essa URL.' }));
}
});
} catch (err) {
console.error('Erro ao iniciar sessão remota:', err.message);
// Travou ou o navegador morreu: joga fora o Chrome pra que a próxima
// tentativa suba um limpo, em vez de repetir a falha pra sempre.
if (/tempo esgotado|Target closed|Session closed|Connection closed|Protocol error/i.test(err.message || '')) {
dropBrowser(err.message);
}
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ t: 'error', message: 'Não foi possível iniciar a sessão. Tente carregar de novo.' }));
}
await cleanup();
return;
}
ws.on('message', async raw => {
if (closed || !client) return;
let msg;
try { msg = JSON.parse(raw); } catch { return; }
if (msg.t !== 'capture') {
lastInputAt = Date.now();
if (!screencastOn) startScreencast(); // voltou a interagir: religa o modo fluido
}
try {
if (msg.t === 'touch') {
const type = { start: 'touchStart', move: 'touchMove', end: 'touchEnd' }[msg.phase];
if (!type) return;
const touchPoints = msg.phase === 'end' ? [] : [{ x: msg.x, y: msg.y }];
await client.send('Input.dispatchTouchEvent', { type, touchPoints });
} else if (msg.t === 'mouse') {
const type = { down: 'mousePressed', move: 'mouseMoved', up: 'mouseReleased' }[msg.phase];
if (!type) return;
await client.send('Input.dispatchMouseEvent', {
type, x: msg.x, y: msg.y, button: 'left', clickCount: msg.phase === 'down' ? 1 : 0,
});
} else if (msg.t === 'wheel') {
await client.send('Input.dispatchMouseEvent', {
type: 'mouseWheel', x: msg.x, y: msg.y, deltaX: msg.deltaX, deltaY: msg.deltaY,
});
} else if (msg.t === 'text') {
await client.send('Input.insertText', { text: msg.text });
} else if (msg.t === 'key') {
const type = msg.phase === 'up' ? 'keyUp' : 'keyDown';
await client.send('Input.dispatchKeyEvent', {
type, key: msg.key, code: msg.code, windowsVirtualKeyCode: msg.keyCode, nativeVirtualKeyCode: msg.keyCode,
});
} else if (msg.t === 'capture') {
// PNG da MESMA página que está sendo navegada (inclusive telas pra onde
// o usuário navegou), não uma nova aba recarregada do zero.
await takeSharp(FRAME_SHOT, 'png');
}
} catch {
// sessão pode já ter fechado; ignora
}
});
});
server.listen(PORT, () => {
console.log(`mydevice rodando em http://localhost:${PORT}`);
console.log(`Usando navegador: ${executablePath}`);
});