import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; import { dirname, resolve } from 'node:path'; const chromePath = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; const url = process.argv[2] || 'http://localhost:7456/'; const out = resolve(process.argv[3] || 'cocos-preview.png'); const port = Number(process.env.CDP_PORT || 9223); const userDataDir = process.env.CDP_USER_DATA_DIR || resolve('tmp/chrome-cdp'); const waitMs = Number(process.env.CDP_WAIT_MS || 10000); const holdRightMs = Number(process.env.CDP_HOLD_RIGHT_MS || 0); const holdFireMs = Number(process.env.CDP_HOLD_FIRE_MS || 0); const evalExpression = process.env.CDP_EVAL || ''; const windowSize = process.env.CDP_WINDOW_SIZE || '1280,720'; function getJson(path) { return new Promise((resolvePromise, reject) => { const req = http.get({ hostname: '127.0.0.1', port, path }, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { resolvePromise(JSON.parse(data)); } catch (error) { reject(error); } }); }); req.on('error', reject); }); } async function waitForChrome() { for (let i = 0; i < 80; i++) { try { return await getJson('/json/version'); } catch { await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } } throw new Error('Chrome DevTools endpoint did not start'); } class CdpSocket { constructor(wsUrl) { const { hostname, port: wsPort, pathname } = new URL(wsUrl); this.host = hostname; this.port = Number(wsPort); this.path = pathname; this.nextId = 1; this.pending = new Map(); this.buffer = Buffer.alloc(0); this.events = []; } connect() { return new Promise((resolvePromise, reject) => { const key = randomBytes(16).toString('base64'); const expected = createHash('sha1') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') .digest('base64'); this.socket = net.createConnection(this.port, this.host, () => { this.socket.write([ `GET ${this.path} HTTP/1.1`, `Host: ${this.host}:${this.port}`, 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Key: ${key}`, 'Sec-WebSocket-Version: 13', '', '' ].join('\r\n')); }); let header = Buffer.alloc(0); const onData = (chunk) => { header = Buffer.concat([header, chunk]); const idx = header.indexOf('\r\n\r\n'); if (idx === -1) return; const head = header.subarray(0, idx).toString('utf8'); if (!head.includes('101') || !head.includes(expected)) { reject(new Error(`WebSocket handshake failed: ${head}`)); return; } this.socket.off('data', onData); const rest = header.subarray(idx + 4); if (rest.length) this.onData(rest); this.socket.on('data', (data) => this.onData(data)); resolvePromise(); }; this.socket.on('data', onData); this.socket.on('error', reject); }); } onData(chunk) { this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= 2) { const b1 = this.buffer[0]; const b2 = this.buffer[1]; let len = b2 & 0x7f; let offset = 2; if (len === 126) { if (this.buffer.length < 4) return; len = this.buffer.readUInt16BE(2); offset = 4; } else if (len === 127) { if (this.buffer.length < 10) return; len = Number(this.buffer.readBigUInt64BE(2)); offset = 10; } const masked = Boolean(b2 & 0x80); const maskOffset = masked ? 4 : 0; if (this.buffer.length < offset + maskOffset + len) return; let payload = this.buffer.subarray(offset + maskOffset, offset + maskOffset + len); if (masked) { const mask = this.buffer.subarray(offset, offset + 4); payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4])); } this.buffer = this.buffer.subarray(offset + maskOffset + len); if ((b1 & 0x0f) === 1) { const msg = JSON.parse(payload.toString('utf8')); if (msg.id && this.pending.has(msg.id)) { this.pending.get(msg.id)(msg); this.pending.delete(msg.id); } else if (msg.method) { this.events.push(msg); } } } } send(method, params = {}) { const id = this.nextId++; const payload = Buffer.from(JSON.stringify({ id, method, params }), 'utf8'); let header; if (payload.length < 126) { header = Buffer.alloc(2); header[1] = 0x80 | payload.length; } else { header = Buffer.alloc(4); header[1] = 0x80 | 126; header.writeUInt16BE(payload.length, 2); } header[0] = 0x81; const mask = randomBytes(4); const masked = Buffer.from(payload.map((value, index) => value ^ mask[index % 4])); const frame = Buffer.concat([header, mask, masked]); return new Promise((resolvePromise) => { this.pending.set(id, resolvePromise); this.socket.write(frame); }).then((msg) => { if (msg.error) throw new Error(`${method}: ${msg.error.message}`); return msg.result; }); } close() { this.socket?.end(); } } mkdirSync(dirname(out), { recursive: true }); const chrome = spawn(chromePath, [ '--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', `--remote-debugging-port=${port}`, `--user-data-dir=${resolve(userDataDir)}`, `--window-size=${windowSize}`, url ], { stdio: 'ignore' }); try { await waitForChrome(); let page; for (let i = 0; i < 40; i++) { const pages = await getJson('/json/list'); page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:7456')) || pages.find((item) => item.type === 'page'); if (page?.webSocketDebuggerUrl) break; await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } if (!page?.webSocketDebuggerUrl) throw new Error('No debuggable page found'); const cdp = new CdpSocket(page.webSocketDebuggerUrl); await cdp.connect(); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); if (holdRightMs > 0) { await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 }); await new Promise((resolvePromise) => setTimeout(resolvePromise, holdRightMs)); await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 }); } if (holdFireMs > 0) { await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'j', code: 'KeyJ', windowsVirtualKeyCode: 74 }); await new Promise((resolvePromise) => setTimeout(resolvePromise, holdFireMs)); await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'j', code: 'KeyJ', windowsVirtualKeyCode: 74 }); } await new Promise((resolvePromise) => setTimeout(resolvePromise, waitMs)); let evalResult = null; if (evalExpression) { evalResult = await cdp.send('Runtime.evaluate', { expression: evalExpression, returnByValue: true }); await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000)); } const title = await cdp.send('Runtime.evaluate', { expression: 'document.title', returnByValue: true }); const canvasInfo = await cdp.send('Runtime.evaluate', { expression: `(() => { const c = document.querySelector('canvas'); return c ? { width: c.width, height: c.height, clientWidth: c.clientWidth, clientHeight: c.clientHeight } : null; })()`, returnByValue: true }); const screenshot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(out, Buffer.from(screenshot.data, 'base64')); cdp.close(); console.log(JSON.stringify({ ok: true, url, output: out, title: title.result.value, canvas: canvasInfo.result.value, evalResult: evalResult?.result?.value ?? null, recentEvents: cdp.events.slice(-20).map((event) => ({ method: event.method, type: event.params?.type, text: event.params?.args?.map((arg) => arg.value || arg.description).filter(Boolean).join(' ') || event.params?.exceptionDetails?.text || '' })) }, null, 2)); } finally { chrome.kill(); }