Monorepo for Aesthetic.Computer
aesthetic.computer
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;