Monorepo for Aesthetic.Computer aesthetic.computer
at main 156 lines 5.3 kB view raw
1// web.mjs - Core CDP wrapper for webmeister browser automation 2// Uses host.docker.internal:9333 for container -> VS Code CDP access 3 4import WebSocket from 'ws'; 5import http from 'http'; 6import fs from 'fs/promises'; 7import path from 'path'; 8import { fileURLToPath } from 'url'; 9 10const __dirname = path.dirname(fileURLToPath(import.meta.url)); 11const CDP_HOST = 'host.docker.internal'; 12const CDP_PORT = 9333; 13const VAULT_PATH = path.join(__dirname, '../aesthetic-computer-vault'); 14 15export class Web { 16 constructor(options = {}) { 17 this.host = options.host || CDP_HOST; 18 this.port = options.port || CDP_PORT; 19 this.ws = null; 20 this.msgId = 1; 21 this.verbose = options.verbose || false; 22 } 23 24 log(...args) { 25 if (this.verbose) console.log('[web]', ...args); 26 } 27 28 async listTargets() { 29 return new Promise((resolve, reject) => { 30 const req = http.get({ 31 hostname: this.host, 32 port: this.port, 33 path: '/json', 34 headers: { 'Host': 'localhost' }, 35 timeout: 5000 36 }, (res) => { 37 let data = ''; 38 res.on('data', chunk => data += chunk); 39 res.on('end', () => { 40 try { resolve(JSON.parse(data)); } 41 catch (e) { reject(new Error('Failed to parse CDP response')); } 42 }); 43 }); 44 req.on('error', e => reject(new Error('CDP not accessible: ' + e.message))); 45 req.on('timeout', () => { req.destroy(); reject(new Error('CDP timeout')); }); 46 }); 47 } 48 49 async findTab(urlPattern) { 50 const targets = await this.listTargets(); 51 const pages = targets.filter(t => t.type === 'page'); 52 if (typeof urlPattern === 'string') { 53 const match = pages.find(t => t.url && t.url.includes(urlPattern)); 54 if (match) return match; 55 } 56 return pages.find(t => !t.url?.includes('workbench.html')) || pages[0]; 57 } 58 59 async connect(urlPattern = null) { 60 if (this.ws?.readyState === WebSocket.OPEN) return; 61 const target = await this.findTab(urlPattern); 62 if (!target) throw new Error('No browser tab found'); 63 this.log('Connecting to:', target.title || target.url); 64 const wsUrl = 'ws://' + this.host + ':' + this.port + '/devtools/page/' + target.id; 65 this.ws = new WebSocket(wsUrl, { headers: { 'Host': 'localhost' } }); 66 await new Promise((resolve, reject) => { 67 const timeout = setTimeout(() => reject(new Error('WS timeout')), 10000); 68 this.ws.on('open', () => { clearTimeout(timeout); resolve(); }); 69 this.ws.on('error', err => { clearTimeout(timeout); reject(err); }); 70 }); 71 this.log('Connected'); 72 } 73 74 async send(method, params = {}) { 75 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error('Not connected'); 76 return new Promise((resolve, reject) => { 77 const id = this.msgId++; 78 const timeout = setTimeout(() => reject(new Error('Timeout: ' + method)), 30000); 79 const handler = data => { 80 try { 81 const msg = JSON.parse(data); 82 if (msg.id === id) { 83 clearTimeout(timeout); 84 this.ws.off('message', handler); 85 msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); 86 } 87 } catch {} 88 }; 89 this.ws.on('message', handler); 90 this.ws.send(JSON.stringify({ id, method, params })); 91 }); 92 } 93 94 async eval(expression) { 95 const result = await this.send('Runtime.evaluate', { expression, returnByValue: true }); 96 if (result.exceptionDetails) throw new Error('Eval error'); 97 return result.result?.value; 98 } 99 100 async goto(url) { 101 this.log('Navigating to:', url); 102 await this.send('Page.navigate', { url }); 103 await this.waitForLoad(); 104 } 105 106 async waitForLoad(timeout = 30000) { 107 const start = Date.now(); 108 while (Date.now() - start < timeout) { 109 try { 110 const state = await this.eval('document.readyState'); 111 if (state === 'complete') return; 112 } catch {} 113 await this.sleep(200); 114 } 115 throw new Error('Page load timeout'); 116 } 117 118 async waitFor(selector, timeout = 10000) { 119 this.log('Waiting for:', selector); 120 const start = Date.now(); 121 while (Date.now() - start < timeout) { 122 try { 123 const found = await this.eval('!!document.querySelector("' + selector.replace('"', '') + '")'); 124 if (found) return true; 125 } catch {} 126 await this.sleep(200); 127 } 128 throw new Error('Selector timeout: ' + selector); 129 } 130 131 async click(selector) { 132 await this.waitFor(selector); 133 await this.eval('document.querySelector("' + selector.replace('"', '') + '").click()'); 134 } 135 136 async type(selector, text) { 137 await this.waitFor(selector); 138 const escaped = JSON.stringify(text); 139 await this.eval('(function(){var e=document.querySelector("' + selector.replace('"', '') + '");e.focus();e.value=' + escaped + ';e.dispatchEvent(new Event("input",{bubbles:true}));})()'); 140 } 141 142 async getPageInfo() { 143 return this.eval('({url:location.href,title:document.title})'); 144 } 145 146 sleep(ms) { return new Promise(r => setTimeout(r, ms)); } 147 close() { if (this.ws) { this.ws.close(); this.ws = null; } } 148 149 static async loadCredentials(domain) { 150 const credPath = path.join(VAULT_PATH, 'gigs', domain, 'credentials.json'); 151 const data = await fs.readFile(credPath, 'utf8'); 152 return JSON.parse(data); 153 } 154} 155 156export default Web;