@romanmatena/browsermonitor 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +558 -0
- package/package.json +53 -0
- package/src/agents.llm/browser-monitor-section.md +18 -0
- package/src/cli.mjs +202 -0
- package/src/http-server.mjs +536 -0
- package/src/init.mjs +162 -0
- package/src/intro.mjs +36 -0
- package/src/logging/LogBuffer.mjs +178 -0
- package/src/logging/constants.mjs +19 -0
- package/src/logging/dump.mjs +207 -0
- package/src/logging/index.mjs +13 -0
- package/src/logging/timestamps.mjs +13 -0
- package/src/monitor/README.md +10 -0
- package/src/monitor/index.mjs +18 -0
- package/src/monitor/interactive-mode.mjs +275 -0
- package/src/monitor/join-mode.mjs +654 -0
- package/src/monitor/open-mode.mjs +889 -0
- package/src/monitor/page-monitoring.mjs +199 -0
- package/src/monitor/tab-selection.mjs +53 -0
- package/src/monitor.mjs +39 -0
- package/src/os/README.md +4 -0
- package/src/os/wsl/chrome.mjs +503 -0
- package/src/os/wsl/detect.mjs +68 -0
- package/src/os/wsl/diagnostics.mjs +729 -0
- package/src/os/wsl/index.mjs +45 -0
- package/src/os/wsl/port-proxy.mjs +190 -0
- package/src/settings.mjs +101 -0
- package/src/templates/api-help.mjs +212 -0
- package/src/templates/cli-commands.mjs +51 -0
- package/src/templates/interactive-keys.mjs +33 -0
- package/src/templates/ready-help.mjs +33 -0
- package/src/templates/section-heading.mjs +141 -0
- package/src/templates/table-helper.mjs +73 -0
- package/src/templates/wait-for-chrome.mjs +19 -0
- package/src/utils/ask.mjs +49 -0
- package/src/utils/chrome-profile-path.mjs +37 -0
- package/src/utils/colors.mjs +49 -0
- package/src/utils/env.mjs +30 -0
- package/src/utils/profile-id.mjs +23 -0
- package/src/utils/status-line.mjs +47 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server for browsermonitor.
|
|
3
|
+
*
|
|
4
|
+
* Provides HTTP API endpoints for LLM/script integration:
|
|
5
|
+
* - GET /dump - Trigger dump; writes files and returns paths + LLM-oriented description
|
|
6
|
+
* - GET /status - Current monitor status
|
|
7
|
+
* - GET /stop, GET /start - Pause/resume collecting
|
|
8
|
+
* - POST /puppeteer - Generic Puppeteer method call: { "method": "page.goto", "args": ["https://..."] }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import { C, log } from './utils/colors.mjs';
|
|
13
|
+
import { getFullTimestamp } from './logging/index.mjs';
|
|
14
|
+
import { getComputedStylesFromPage } from './logging/dump.mjs';
|
|
15
|
+
import { printApiHelpTable, API_ENDPOINTS } from './templates/api-help.mjs';
|
|
16
|
+
import { printSectionHeading } from './templates/section-heading.mjs';
|
|
17
|
+
|
|
18
|
+
/** Default timeout for Puppeteer operations (ms). */
|
|
19
|
+
const PUPPETEER_CALL_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/** Allowed page.* methods for POST /puppeteer (no evaluate by default for safety). */
|
|
22
|
+
const PAGE_WHITELIST = new Set([
|
|
23
|
+
'goto', 'click', 'type', 'focus', 'hover', 'select',
|
|
24
|
+
'content', 'title', 'url',
|
|
25
|
+
'screenshot', 'pdf',
|
|
26
|
+
'setViewport', 'setDefaultTimeout', 'setDefaultNavigationTimeout',
|
|
27
|
+
'waitForSelector', 'waitForTimeout',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read request body as UTF-8 string.
|
|
32
|
+
* @param {http.IncomingMessage} req
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
function readBody(req) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
39
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serialize Puppeteer method return value for JSON response.
|
|
46
|
+
* @param {unknown} result
|
|
47
|
+
* @param {string} methodName - e.g. 'goto'
|
|
48
|
+
* @returns {{ serialized: unknown } | { error: string }}
|
|
49
|
+
*/
|
|
50
|
+
function serializeResult(result, methodName) {
|
|
51
|
+
if (result === undefined) return { serialized: null };
|
|
52
|
+
if (Buffer.isBuffer(result)) return { serialized: result.toString('base64') };
|
|
53
|
+
if (result !== null && typeof result === 'object' && typeof result.url === 'function' && typeof result.status === 'function') {
|
|
54
|
+
try {
|
|
55
|
+
return { serialized: { url: result.url(), status: result.status() } };
|
|
56
|
+
} catch {
|
|
57
|
+
return { error: 'Failed to serialize response object' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
JSON.stringify(result);
|
|
62
|
+
return { serialized: result };
|
|
63
|
+
} catch {
|
|
64
|
+
return { error: 'Method returns non-serializable value' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create and start the HTTP server for monitor API.
|
|
70
|
+
* Supports two modes:
|
|
71
|
+
* - getState(): use a shared state object (for early start before open/join). State can have logBuffer null = no browser.
|
|
72
|
+
* - Direct options (logBuffer, getPages, ...): classic per-mode server.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} options - Server options
|
|
75
|
+
* @param {number} options.port - Port to listen on (default: 60001, 0 = disabled)
|
|
76
|
+
* @param {string} options.host - Host to bind (default: 127.0.0.1)
|
|
77
|
+
* @param {Function} [options.getState] - () => ({ mode, logBuffer, getPages, getCollectingPaused, setCollectingPaused }) for shared state
|
|
78
|
+
* @param {string} [options.mode] - Monitor mode when not using getState
|
|
79
|
+
* @param {Object} [options.logBuffer] - LogBuffer when not using getState
|
|
80
|
+
* @param {Function} [options.getPages] - When not using getState
|
|
81
|
+
* @param {Function} [options.getCollectingPaused] - When not using getState
|
|
82
|
+
* @param {Function} [options.setCollectingPaused] - When not using getState
|
|
83
|
+
* @param {Function} [options.onDump] - Optional callback when dump is requested
|
|
84
|
+
* @param {number} [options.defaultPort=60001] - Default port; if port differs, "(changed)" is shown
|
|
85
|
+
* @returns {http.Server|null} Server instance or null if port is 0
|
|
86
|
+
*/
|
|
87
|
+
export function createHttpServer(options) {
|
|
88
|
+
const {
|
|
89
|
+
port = 60001,
|
|
90
|
+
host = '127.0.0.1',
|
|
91
|
+
defaultPort = 60001,
|
|
92
|
+
getState = null,
|
|
93
|
+
mode = 'unknown',
|
|
94
|
+
logBuffer,
|
|
95
|
+
getPages = () => [],
|
|
96
|
+
getCollectingPaused = () => false,
|
|
97
|
+
setCollectingPaused = () => {},
|
|
98
|
+
onDump = null,
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
if (port === 0) {
|
|
102
|
+
log.dim('HTTP server disabled (port 0)');
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function state() {
|
|
107
|
+
if (getState) {
|
|
108
|
+
const s = getState();
|
|
109
|
+
return {
|
|
110
|
+
mode: s.mode ?? 'interactive',
|
|
111
|
+
logBuffer: s.logBuffer ?? null,
|
|
112
|
+
getPages: s.getPages ?? (() => []),
|
|
113
|
+
getCollectingPaused: s.getCollectingPaused ?? (() => false),
|
|
114
|
+
setCollectingPaused: s.setCollectingPaused ?? (() => {}),
|
|
115
|
+
switchToTab: s.switchToTab ?? (async () => ({ success: false, error: 'Not available' })),
|
|
116
|
+
getAllTabs: s.getAllTabs ?? (async () => []),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
mode,
|
|
121
|
+
logBuffer,
|
|
122
|
+
getPages,
|
|
123
|
+
getCollectingPaused,
|
|
124
|
+
setCollectingPaused,
|
|
125
|
+
switchToTab: async () => ({ success: false, error: 'Not available' }),
|
|
126
|
+
getAllTabs: async () => [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const server = http.createServer(async (req, res) => {
|
|
131
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
132
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
133
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
134
|
+
|
|
135
|
+
if (req.method === 'OPTIONS') {
|
|
136
|
+
res.writeHead(200);
|
|
137
|
+
res.end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const s = state();
|
|
142
|
+
const noBrowser = !s.logBuffer;
|
|
143
|
+
|
|
144
|
+
// GET /dump
|
|
145
|
+
if (req.url === '/dump' && req.method === 'GET') {
|
|
146
|
+
if (noBrowser) {
|
|
147
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
148
|
+
res.end(JSON.stringify({
|
|
149
|
+
success: false,
|
|
150
|
+
state: s.mode,
|
|
151
|
+
message: 'No browser connected. Choose open (o) or join (j) in interactive mode, or use --open / --join.',
|
|
152
|
+
timestamp: getFullTimestamp(),
|
|
153
|
+
endpoints: { status: 'GET /status', dump: 'GET /dump (after browser connected)' },
|
|
154
|
+
}, null, 2));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const statsBeforeDump = s.logBuffer.getStats();
|
|
159
|
+
const pages = s.getPages();
|
|
160
|
+
const page = pages.length > 0 ? pages[0] : null;
|
|
161
|
+
|
|
162
|
+
await s.logBuffer.dumpBuffersToFiles({
|
|
163
|
+
dumpCookies: page ? () => s.logBuffer.dumpCookiesFromPage(page) : null,
|
|
164
|
+
dumpDom: page ? () => s.logBuffer.dumpDomFromPage(page) : null,
|
|
165
|
+
dumpScreenshot: page ? () => s.logBuffer.dumpScreenshotFromPage(page) : null,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (onDump) onDump();
|
|
169
|
+
|
|
170
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
171
|
+
res.end(JSON.stringify({
|
|
172
|
+
success: true,
|
|
173
|
+
timestamp: getFullTimestamp(),
|
|
174
|
+
message: 'Dump completed. Read the files below.',
|
|
175
|
+
stats: statsBeforeDump,
|
|
176
|
+
outputFiles: {
|
|
177
|
+
consoleLog: s.logBuffer.CONSOLE_LOG,
|
|
178
|
+
networkLog: s.logBuffer.NETWORK_LOG,
|
|
179
|
+
networkDir: s.logBuffer.NETWORK_DIR,
|
|
180
|
+
cookiesDir: s.logBuffer.COOKIES_DIR,
|
|
181
|
+
domHtml: s.logBuffer.DOM_HTML,
|
|
182
|
+
screenshot: s.logBuffer.SCREENSHOT,
|
|
183
|
+
},
|
|
184
|
+
llm: {
|
|
185
|
+
instruction: 'Read or download these files to get the current browser state. Do not ask the user to copy/paste from the browser.',
|
|
186
|
+
files: [
|
|
187
|
+
{ path: s.logBuffer.DOM_HTML, what: 'Current page HTML (JS-modified DOM). Use for element tree and structure.' },
|
|
188
|
+
{ path: s.logBuffer.SCREENSHOT, what: 'Screenshot of the current tab viewport (PNG).' },
|
|
189
|
+
{ path: s.logBuffer.CONSOLE_LOG, what: 'Browser console output (logs, errors, warnings).' },
|
|
190
|
+
{ path: s.logBuffer.NETWORK_LOG, what: 'Network requests overview (one line per request with ID).' },
|
|
191
|
+
{ path: s.logBuffer.NETWORK_DIR, what: 'Directory with one JSON per request: full headers, payload, response (see IDs in network log).' },
|
|
192
|
+
{ path: s.logBuffer.COOKIES_DIR, what: 'Directory with cookies per domain (JSON files).' },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
}, null, 2));
|
|
196
|
+
} catch (error) {
|
|
197
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
198
|
+
res.end(JSON.stringify({ success: false, error: error.message }, null, 2));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// GET /status
|
|
204
|
+
if (req.url === '/status' && req.method === 'GET') {
|
|
205
|
+
const pages = s.getPages();
|
|
206
|
+
const collectingPaused = s.getCollectingPaused();
|
|
207
|
+
const payload = {
|
|
208
|
+
status: noBrowser ? 'interactive' : 'running',
|
|
209
|
+
mode: s.mode,
|
|
210
|
+
timestamp: getFullTimestamp(),
|
|
211
|
+
monitoredPages: noBrowser ? [] : pages.map(p => {
|
|
212
|
+
try { return p.url(); } catch { return 'unknown'; }
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
if (!noBrowser) {
|
|
216
|
+
payload.collecting = collectingPaused ? 'paused' : 'running';
|
|
217
|
+
payload.stats = s.logBuffer.getStats();
|
|
218
|
+
payload.outputFiles = {
|
|
219
|
+
consoleLog: s.logBuffer.CONSOLE_LOG,
|
|
220
|
+
networkLog: s.logBuffer.NETWORK_LOG,
|
|
221
|
+
networkDir: s.logBuffer.NETWORK_DIR,
|
|
222
|
+
cookiesDir: s.logBuffer.COOKIES_DIR,
|
|
223
|
+
domHtml: s.logBuffer.DOM_HTML,
|
|
224
|
+
screenshot: s.logBuffer.SCREENSHOT,
|
|
225
|
+
};
|
|
226
|
+
} else {
|
|
227
|
+
payload.message = 'No browser. Use interactive (o/j) or --open / --join.';
|
|
228
|
+
}
|
|
229
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
230
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// GET /stop
|
|
235
|
+
if (req.url === '/stop' && req.method === 'GET') {
|
|
236
|
+
s.setCollectingPaused(true);
|
|
237
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify({
|
|
239
|
+
success: true,
|
|
240
|
+
collecting: 'paused',
|
|
241
|
+
message: noBrowser ? 'No browser.' : 'Collecting stopped (paused). Use /start to resume.',
|
|
242
|
+
timestamp: getFullTimestamp(),
|
|
243
|
+
}, null, 2));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// GET /start
|
|
248
|
+
if (req.url === '/start' && req.method === 'GET') {
|
|
249
|
+
s.setCollectingPaused(false);
|
|
250
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
251
|
+
res.end(JSON.stringify({
|
|
252
|
+
success: true,
|
|
253
|
+
collecting: 'running',
|
|
254
|
+
message: noBrowser ? 'No browser.' : 'Collecting started (resumed).',
|
|
255
|
+
timestamp: getFullTimestamp(),
|
|
256
|
+
}, null, 2));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// GET /clear
|
|
261
|
+
if (req.url === '/clear' && req.method === 'GET') {
|
|
262
|
+
if (noBrowser) {
|
|
263
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
264
|
+
res.end(JSON.stringify({
|
|
265
|
+
success: false,
|
|
266
|
+
message: 'No browser connected.',
|
|
267
|
+
timestamp: getFullTimestamp(),
|
|
268
|
+
}, null, 2));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
s.logBuffer.clearAllBuffers();
|
|
272
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
273
|
+
res.end(JSON.stringify({
|
|
274
|
+
success: true,
|
|
275
|
+
message: 'Buffers cleared.',
|
|
276
|
+
timestamp: getFullTimestamp(),
|
|
277
|
+
}, null, 2));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// GET /tabs - list all user tabs (for /tab?index=N)
|
|
282
|
+
if (req.url === '/tabs' && req.method === 'GET') {
|
|
283
|
+
if (noBrowser) {
|
|
284
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
285
|
+
res.end(JSON.stringify({ tabs: [], message: 'No browser connected.' }, null, 2));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const tabs = await s.getAllTabs();
|
|
290
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
291
|
+
res.end(JSON.stringify({ tabs, timestamp: getFullTimestamp() }, null, 2));
|
|
292
|
+
} catch (err) {
|
|
293
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify({ error: err.message }, null, 2));
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// GET /computed-styles?selector=...
|
|
300
|
+
if (req.url?.startsWith('/computed-styles') && req.method === 'GET') {
|
|
301
|
+
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
302
|
+
const selector = urlObj.searchParams.get('selector') || 'body';
|
|
303
|
+
if (noBrowser) {
|
|
304
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
305
|
+
res.end(JSON.stringify({
|
|
306
|
+
success: false,
|
|
307
|
+
message: 'No browser connected.',
|
|
308
|
+
timestamp: getFullTimestamp(),
|
|
309
|
+
}, null, 2));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const pages = s.getPages();
|
|
313
|
+
const page = pages.length > 0 ? pages[0] : null;
|
|
314
|
+
if (!page) {
|
|
315
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
316
|
+
res.end(JSON.stringify({
|
|
317
|
+
success: false,
|
|
318
|
+
message: 'No monitored page.',
|
|
319
|
+
timestamp: getFullTimestamp(),
|
|
320
|
+
}, null, 2));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const result = await getComputedStylesFromPage(page, selector);
|
|
325
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
326
|
+
res.end(JSON.stringify({
|
|
327
|
+
success: !result.error,
|
|
328
|
+
selector,
|
|
329
|
+
timestamp: getFullTimestamp(),
|
|
330
|
+
...result,
|
|
331
|
+
}, null, 2));
|
|
332
|
+
} catch (err) {
|
|
333
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
334
|
+
res.end(JSON.stringify({
|
|
335
|
+
success: false,
|
|
336
|
+
error: err.message,
|
|
337
|
+
timestamp: getFullTimestamp(),
|
|
338
|
+
}, null, 2));
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// GET /tab or /tab?index=1
|
|
344
|
+
if (req.url?.startsWith('/tab') && req.url !== '/tabs' && req.method === 'GET') {
|
|
345
|
+
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
346
|
+
const indexParam = urlObj.searchParams.get('index');
|
|
347
|
+
const index = indexParam ? parseInt(indexParam, 10) : NaN;
|
|
348
|
+
if (!Number.isInteger(index) || index < 1) {
|
|
349
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
350
|
+
res.end(JSON.stringify({
|
|
351
|
+
success: false,
|
|
352
|
+
error: 'Missing or invalid index. Use /tab?index=1 (1-based tab number).',
|
|
353
|
+
timestamp: getFullTimestamp(),
|
|
354
|
+
}, null, 2));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const result = await s.switchToTab(index);
|
|
359
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
360
|
+
res.end(JSON.stringify({
|
|
361
|
+
success: result.success,
|
|
362
|
+
url: result.url,
|
|
363
|
+
error: result.error,
|
|
364
|
+
timestamp: getFullTimestamp(),
|
|
365
|
+
}, null, 2));
|
|
366
|
+
} catch (err) {
|
|
367
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
368
|
+
res.end(JSON.stringify({
|
|
369
|
+
success: false,
|
|
370
|
+
error: err.message,
|
|
371
|
+
timestamp: getFullTimestamp(),
|
|
372
|
+
}, null, 2));
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// POST /puppeteer - generic Puppeteer page method call
|
|
378
|
+
const pathname = req.url?.split('?')[0];
|
|
379
|
+
if (pathname === '/puppeteer' && req.method === 'POST') {
|
|
380
|
+
let body;
|
|
381
|
+
try {
|
|
382
|
+
const raw = await readBody(req);
|
|
383
|
+
body = raw ? JSON.parse(raw) : {};
|
|
384
|
+
} catch (e) {
|
|
385
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
386
|
+
res.end(JSON.stringify({
|
|
387
|
+
success: false,
|
|
388
|
+
error: 'Invalid JSON body',
|
|
389
|
+
timestamp: getFullTimestamp(),
|
|
390
|
+
}, null, 2));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const method = body.method;
|
|
394
|
+
const args = Array.isArray(body.args) ? body.args : [];
|
|
395
|
+
const timeout = typeof body.timeout === 'number' ? body.timeout : PUPPETEER_CALL_TIMEOUT_MS;
|
|
396
|
+
const waitFor = body.waitFor === true; // auto waitForSelector before click/hover
|
|
397
|
+
if (typeof method !== 'string' || !method.trim()) {
|
|
398
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
399
|
+
res.end(JSON.stringify({
|
|
400
|
+
success: false,
|
|
401
|
+
error: 'Missing or invalid "method" (e.g. "page.goto")',
|
|
402
|
+
timestamp: getFullTimestamp(),
|
|
403
|
+
}, null, 2));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (!method.startsWith('page.')) {
|
|
407
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
408
|
+
res.end(JSON.stringify({
|
|
409
|
+
success: false,
|
|
410
|
+
error: 'Only "page.*" methods are supported (e.g. "page.goto", "page.click")',
|
|
411
|
+
timestamp: getFullTimestamp(),
|
|
412
|
+
}, null, 2));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const methodName = method.slice(5).trim();
|
|
416
|
+
if (!methodName || !PAGE_WHITELIST.has(methodName)) {
|
|
417
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
418
|
+
res.end(JSON.stringify({
|
|
419
|
+
success: false,
|
|
420
|
+
error: `Method "${method}" not allowed. Whitelist: ${[...PAGE_WHITELIST].sort().join(', ')}`,
|
|
421
|
+
timestamp: getFullTimestamp(),
|
|
422
|
+
}, null, 2));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const pages = s.getPages();
|
|
426
|
+
const page = pages.length > 0 ? pages[0] : null;
|
|
427
|
+
if (noBrowser || !page) {
|
|
428
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
429
|
+
res.end(JSON.stringify({
|
|
430
|
+
success: false,
|
|
431
|
+
error: 'No browser or page connected. Use open/join mode first.',
|
|
432
|
+
timestamp: getFullTimestamp(),
|
|
433
|
+
}, null, 2));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
let callArgs = args;
|
|
437
|
+
if (methodName === 'screenshot') {
|
|
438
|
+
const opts = (args[0] && typeof args[0] === 'object' && !Array.isArray(args[0]))
|
|
439
|
+
? { ...args[0], encoding: args[0].encoding ?? 'base64' }
|
|
440
|
+
: { encoding: 'base64' };
|
|
441
|
+
callArgs = [opts];
|
|
442
|
+
}
|
|
443
|
+
if (methodName === 'pdf' && args[0] && typeof args[0] === 'object') {
|
|
444
|
+
callArgs = [{ ...args[0], encoding: args[0].encoding ?? 'base64' }];
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
// Auto waitForSelector before click/hover if requested
|
|
448
|
+
if (waitFor && ['click', 'hover', 'focus', 'type'].includes(methodName) && callArgs[0]) {
|
|
449
|
+
const selector = callArgs[0];
|
|
450
|
+
if (typeof selector === 'string') {
|
|
451
|
+
await page.waitForSelector(selector, { timeout });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const fn = page[methodName];
|
|
455
|
+
if (typeof fn !== 'function') {
|
|
456
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
457
|
+
res.end(JSON.stringify({
|
|
458
|
+
success: false,
|
|
459
|
+
error: `Page method "${methodName}" is not a function`,
|
|
460
|
+
timestamp: getFullTimestamp(),
|
|
461
|
+
}, null, 2));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const resultPromise = fn.apply(page, callArgs);
|
|
465
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
466
|
+
setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout);
|
|
467
|
+
});
|
|
468
|
+
const result = await Promise.race([resultPromise, timeoutPromise]);
|
|
469
|
+
const serialized = serializeResult(result, methodName);
|
|
470
|
+
if ('error' in serialized) {
|
|
471
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
472
|
+
res.end(JSON.stringify({
|
|
473
|
+
success: false,
|
|
474
|
+
error: serialized.error,
|
|
475
|
+
timestamp: getFullTimestamp(),
|
|
476
|
+
}, null, 2));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
480
|
+
res.end(JSON.stringify({
|
|
481
|
+
success: true,
|
|
482
|
+
result: serialized.serialized,
|
|
483
|
+
timestamp: getFullTimestamp(),
|
|
484
|
+
}, null, 2));
|
|
485
|
+
} catch (err) {
|
|
486
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
487
|
+
res.end(JSON.stringify({
|
|
488
|
+
success: false,
|
|
489
|
+
error: err.message || String(err),
|
|
490
|
+
timestamp: getFullTimestamp(),
|
|
491
|
+
}, null, 2));
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
497
|
+
res.end(JSON.stringify({
|
|
498
|
+
error: 'Not found',
|
|
499
|
+
endpoints: API_ENDPOINTS.map(e => `${e.method} ${e.path} - ${e.description}`),
|
|
500
|
+
}, null, 2));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
server.listen(port, host, () => {
|
|
504
|
+
const url = `http://${host}:${port}`;
|
|
505
|
+
const changed = port !== defaultPort ? ` ${C.red}(changed)${C.reset}` : '';
|
|
506
|
+
const title = `HTTP API URL: ${url}${changed}`;
|
|
507
|
+
printSectionHeading(title, ' ');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
server.on('error', (err) => {
|
|
511
|
+
if (err.code === 'EADDRINUSE') {
|
|
512
|
+
log.warn(`HTTP server port ${port} is already in use`);
|
|
513
|
+
} else {
|
|
514
|
+
log.error(`HTTP server error: ${err.message}`);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return server;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Close the HTTP server gracefully.
|
|
523
|
+
* @param {http.Server} server - Server to close
|
|
524
|
+
* @returns {Promise<void>}
|
|
525
|
+
*/
|
|
526
|
+
export function closeHttpServer(server) {
|
|
527
|
+
return new Promise((resolve) => {
|
|
528
|
+
if (!server) {
|
|
529
|
+
resolve();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
server.close(() => {
|
|
533
|
+
resolve();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browsermonitor init – first-run setup and agent file updates.
|
|
3
|
+
*
|
|
4
|
+
* Called by:
|
|
5
|
+
* - `browsermonitor init` subcommand (explicit)
|
|
6
|
+
* - Auto-init on first run when .browsermonitor/ does not exist
|
|
7
|
+
*
|
|
8
|
+
* What it does:
|
|
9
|
+
* 1. Creates .browsermonitor/ directory structure
|
|
10
|
+
* 2. Creates settings.json with defaults (prompts for URL if TTY)
|
|
11
|
+
* 3. Updates CLAUDE.md, AGENTS.md, memory.md with Browser Monitor section
|
|
12
|
+
* 4. Suggests adding .browsermonitor/ to .gitignore
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_SETTINGS,
|
|
20
|
+
ensureDirectories,
|
|
21
|
+
getPaths,
|
|
22
|
+
saveSettings,
|
|
23
|
+
} from './settings.mjs';
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
const BEGIN_TAG_PREFIX = '<!-- BEGIN browser-monitor-llm-section';
|
|
28
|
+
const END_TAG_PREFIX = '<!-- END browser-monitor-llm-section';
|
|
29
|
+
|
|
30
|
+
const TEMPLATE_PATH = path.resolve(__dirname, 'agents.llm/browser-monitor-section.md');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace existing tagged block or append template to a doc file.
|
|
34
|
+
* Section is identified by BEGIN/END tags.
|
|
35
|
+
* @param {string} hostDir
|
|
36
|
+
* @param {string} docFilename - e.g. 'CLAUDE.md', 'AGENTS.md', 'memory.md'
|
|
37
|
+
* @param {string} templateContent - full block including BEGIN and END tags
|
|
38
|
+
* @returns {boolean} true if file was updated
|
|
39
|
+
*/
|
|
40
|
+
function replaceOrAppendSection(hostDir, docFilename, templateContent) {
|
|
41
|
+
const hostPath = path.join(hostDir, docFilename);
|
|
42
|
+
if (!fs.existsSync(hostPath)) return false;
|
|
43
|
+
|
|
44
|
+
const content = fs.readFileSync(hostPath, 'utf8');
|
|
45
|
+
const trimmedTemplate = templateContent.trimEnd();
|
|
46
|
+
const beginIndex = content.indexOf(BEGIN_TAG_PREFIX);
|
|
47
|
+
|
|
48
|
+
let newContent;
|
|
49
|
+
if (beginIndex === -1) {
|
|
50
|
+
newContent = content.trimEnd() + '\n\n' + trimmedTemplate + '\n';
|
|
51
|
+
console.log(`[browsermonitor] Appended Browser Monitor section to ${docFilename}`);
|
|
52
|
+
} else {
|
|
53
|
+
const endTagStartIndex = content.indexOf(END_TAG_PREFIX, beginIndex);
|
|
54
|
+
if (endTagStartIndex === -1) {
|
|
55
|
+
console.error(`[browsermonitor] ${docFilename}: BEGIN tag found but no END tag; skipping`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const afterEndComment = content.indexOf('-->', endTagStartIndex) + 3;
|
|
59
|
+
const lineEnd = content.indexOf('\n', afterEndComment);
|
|
60
|
+
const endIndex = lineEnd === -1 ? content.length : lineEnd + 1;
|
|
61
|
+
newContent = content.slice(0, beginIndex) + trimmedTemplate + '\n' + content.slice(endIndex);
|
|
62
|
+
console.log(`[browsermonitor] Replaced Browser Monitor section in ${docFilename}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(hostPath, newContent);
|
|
67
|
+
return true;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`[browsermonitor] Could not write ${docFilename}:`, err.message);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Prompt user for default URL (stdin line read).
|
|
76
|
+
* @param {string} defaultValue
|
|
77
|
+
* @returns {Promise<string>}
|
|
78
|
+
*/
|
|
79
|
+
function askDefaultUrl(defaultValue) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
process.stdout.write(` Default URL [${defaultValue}]: `);
|
|
82
|
+
process.stdin.resume();
|
|
83
|
+
process.stdin.setEncoding('utf8');
|
|
84
|
+
process.stdin.once('data', (chunk) => {
|
|
85
|
+
process.stdin.pause();
|
|
86
|
+
const trimmed = chunk.toString().trim().split('\n')[0].trim();
|
|
87
|
+
resolve(trimmed || defaultValue);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Suggest adding .browsermonitor/ to .gitignore if not already present.
|
|
94
|
+
* @param {string} projectRoot
|
|
95
|
+
*/
|
|
96
|
+
function suggestGitignore(projectRoot) {
|
|
97
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
98
|
+
if (!fs.existsSync(gitignorePath)) return;
|
|
99
|
+
|
|
100
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
101
|
+
if (content.includes('.browsermonitor')) return;
|
|
102
|
+
|
|
103
|
+
console.log(`[browsermonitor] Tip: add .browsermonitor/ to your .gitignore`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run browsermonitor initialization.
|
|
108
|
+
* @param {string} projectRoot - Absolute path to the project
|
|
109
|
+
* @param {Object} [options]
|
|
110
|
+
* @param {boolean} [options.askForUrl=true] - Prompt for default URL
|
|
111
|
+
* @param {boolean} [options.updateAgentFiles=true] - Update CLAUDE.md/AGENTS.md/memory.md
|
|
112
|
+
*/
|
|
113
|
+
export async function runInit(projectRoot, options = {}) {
|
|
114
|
+
const { askForUrl = true, updateAgentFiles = true } = options;
|
|
115
|
+
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log('========================================');
|
|
118
|
+
console.log(' Browser Monitor - Setup');
|
|
119
|
+
console.log('========================================');
|
|
120
|
+
console.log('');
|
|
121
|
+
console.log(`[browsermonitor] Project: ${projectRoot}`);
|
|
122
|
+
console.log('');
|
|
123
|
+
|
|
124
|
+
// 1. Create directory structure
|
|
125
|
+
ensureDirectories(projectRoot);
|
|
126
|
+
console.log('[browsermonitor] Created .browsermonitor/ directory structure');
|
|
127
|
+
|
|
128
|
+
// 2. Create settings.json if it doesn't exist
|
|
129
|
+
const { settingsFile } = getPaths(projectRoot);
|
|
130
|
+
if (!fs.existsSync(settingsFile)) {
|
|
131
|
+
let defaultUrl = DEFAULT_SETTINGS.defaultUrl;
|
|
132
|
+
if (askForUrl && process.stdin.isTTY) {
|
|
133
|
+
defaultUrl = await askDefaultUrl(defaultUrl);
|
|
134
|
+
}
|
|
135
|
+
const settings = { ...DEFAULT_SETTINGS, defaultUrl };
|
|
136
|
+
saveSettings(projectRoot, settings);
|
|
137
|
+
console.log(`[browsermonitor] Created settings.json (defaultUrl: ${defaultUrl})`);
|
|
138
|
+
} else {
|
|
139
|
+
console.log('[browsermonitor] settings.json already exists, skipping');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 3. Update agent files
|
|
143
|
+
if (updateAgentFiles) {
|
|
144
|
+
if (!fs.existsSync(TEMPLATE_PATH)) {
|
|
145
|
+
console.error('[browsermonitor] Agent template not found:', TEMPLATE_PATH);
|
|
146
|
+
} else {
|
|
147
|
+
const templateContent = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
148
|
+
replaceOrAppendSection(projectRoot, 'CLAUDE.md', templateContent);
|
|
149
|
+
replaceOrAppendSection(projectRoot, 'AGENTS.md', templateContent);
|
|
150
|
+
replaceOrAppendSection(projectRoot, 'memory.md', templateContent);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 4. Suggest .gitignore
|
|
155
|
+
suggestGitignore(projectRoot);
|
|
156
|
+
|
|
157
|
+
console.log('');
|
|
158
|
+
console.log('[browsermonitor] Setup complete.');
|
|
159
|
+
console.log(' browsermonitor - Interactive menu (o=open, j=join, q=quit)');
|
|
160
|
+
console.log(' browsermonitor --open - Launch Chrome at default URL');
|
|
161
|
+
console.log('');
|
|
162
|
+
}
|