browser-pilot-cli 0.1.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/dist/daemon.js ADDED
@@ -0,0 +1,516 @@
1
+ // src/daemon.ts
2
+ import http from "http";
3
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from "fs";
4
+
5
+ // src/paths.ts
6
+ import { join } from "path";
7
+ import { homedir } from "os";
8
+ var STATE_DIR = join(homedir(), ".browser-pilot");
9
+ var STATE_FILE = join(STATE_DIR, "state.json");
10
+ var SOCKET_PATH = join(STATE_DIR, "daemon.sock");
11
+ var PID_FILE = join(STATE_DIR, "daemon.pid");
12
+ var REFS_FILE = join(STATE_DIR, "refs.json");
13
+
14
+ // src/cdp.ts
15
+ import WebSocket from "ws";
16
+ var CDPClient = class {
17
+ ws;
18
+ nextId = 1;
19
+ callbacks = /* @__PURE__ */ new Map();
20
+ eventHandlers = /* @__PURE__ */ new Map();
21
+ connect(wsUrl2) {
22
+ return new Promise((resolve, reject) => {
23
+ this.ws = new WebSocket(wsUrl2);
24
+ this.ws.on("error", (err) => {
25
+ for (const cb of this.callbacks.values()) {
26
+ cb.reject(new Error(`WebSocket error: ${err.message}`));
27
+ }
28
+ this.callbacks.clear();
29
+ });
30
+ const onError = (err) => {
31
+ this.ws.removeListener("open", onOpen);
32
+ reject(err);
33
+ };
34
+ const onOpen = () => {
35
+ this.ws.removeListener("error", onError);
36
+ resolve();
37
+ };
38
+ this.ws.once("open", onOpen);
39
+ this.ws.once("error", onError);
40
+ this.ws.on("message", (data) => {
41
+ const msg = JSON.parse(data.toString());
42
+ if ("id" in msg) {
43
+ const cb = this.callbacks.get(msg.id);
44
+ if (cb) {
45
+ this.callbacks.delete(msg.id);
46
+ msg.error ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result ?? {});
47
+ }
48
+ } else if (msg.method) {
49
+ if (msg.sessionId) {
50
+ for (const h of this.eventHandlers.get(`${msg.sessionId}:${msg.method}`) ?? []) {
51
+ h(msg.params, msg.sessionId);
52
+ }
53
+ }
54
+ for (const h of this.eventHandlers.get(msg.method) ?? []) {
55
+ h(msg.params, msg.sessionId);
56
+ }
57
+ }
58
+ });
59
+ this.ws.on("close", () => {
60
+ for (const cb of this.callbacks.values()) {
61
+ cb.reject(new Error("Connection closed"));
62
+ }
63
+ this.callbacks.clear();
64
+ });
65
+ });
66
+ }
67
+ send(method, params, sessionId) {
68
+ return new Promise((resolve, reject) => {
69
+ const id = this.nextId++;
70
+ const msg = { id, method };
71
+ if (params && Object.keys(params).length > 0) msg.params = params;
72
+ if (sessionId) msg.sessionId = sessionId;
73
+ const timeout = setTimeout(() => {
74
+ this.callbacks.delete(id);
75
+ reject(new Error(`CDP timeout: ${method}`));
76
+ }, 3e4);
77
+ this.callbacks.set(id, {
78
+ resolve: (v) => {
79
+ clearTimeout(timeout);
80
+ resolve(v);
81
+ },
82
+ reject: (e) => {
83
+ clearTimeout(timeout);
84
+ reject(e);
85
+ }
86
+ });
87
+ this.ws.send(JSON.stringify(msg));
88
+ });
89
+ }
90
+ on(method, handler) {
91
+ const handlers = this.eventHandlers.get(method) ?? [];
92
+ handlers.push(handler);
93
+ this.eventHandlers.set(method, handlers);
94
+ }
95
+ close() {
96
+ if (this.ws) this.ws.close();
97
+ }
98
+ };
99
+
100
+ // src/daemon.ts
101
+ var wsUrl = process.argv[2];
102
+ if (!wsUrl) {
103
+ process.stderr.write("Usage: daemon <wsUrl>\n");
104
+ process.exit(1);
105
+ }
106
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 448 });
107
+ else try {
108
+ chmodSync(STATE_DIR, 448);
109
+ } catch {
110
+ }
111
+ try {
112
+ unlinkSync(SOCKET_PATH);
113
+ } catch {
114
+ }
115
+ function cleanup() {
116
+ try {
117
+ unlinkSync(SOCKET_PATH);
118
+ } catch {
119
+ }
120
+ try {
121
+ unlinkSync(PID_FILE);
122
+ } catch {
123
+ }
124
+ }
125
+ function readBody(req) {
126
+ return new Promise((resolve, reject) => {
127
+ let body = "";
128
+ req.on("data", (chunk) => {
129
+ body += chunk.toString();
130
+ });
131
+ req.on("end", () => resolve(body));
132
+ req.on("error", reject);
133
+ });
134
+ }
135
+ var authCredentials = null;
136
+ var handledDialogs = [];
137
+ var discoveredTargets = [];
138
+ var nextReqId = 1;
139
+ var MAX_TRACKED = 1e3;
140
+ var trackedRequests = [];
141
+ var requestsByNetworkId = /* @__PURE__ */ new Map();
142
+ var networkEnabledSessions = /* @__PURE__ */ new Set();
143
+ var nextRuleId = 1;
144
+ var interceptRules = [];
145
+ var fetchEnabledSessions = /* @__PURE__ */ new Set();
146
+ function wildcardMatch(url, pattern) {
147
+ try {
148
+ const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$", "i");
149
+ return re.test(url);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ async function syncFetch(cdp, sessionId) {
155
+ if (!sessionId) return;
156
+ const need = interceptRules.length > 0;
157
+ if (need) {
158
+ await cdp.send("Fetch.enable", { patterns: [{ urlPattern: "*" }], handleAuthRequests: !!authCredentials }, sessionId).catch(() => {
159
+ });
160
+ fetchEnabledSessions.add(sessionId);
161
+ } else if (fetchEnabledSessions.has(sessionId)) {
162
+ if (authCredentials) {
163
+ await cdp.send("Fetch.enable", { handleAuthRequests: true }, sessionId).catch(() => {
164
+ });
165
+ } else {
166
+ await cdp.send("Fetch.disable", {}, sessionId).catch(() => {
167
+ });
168
+ }
169
+ fetchEnabledSessions.delete(sessionId);
170
+ }
171
+ }
172
+ async function enableNetworkTracking(cdp, sessionId) {
173
+ if (!sessionId || networkEnabledSessions.has(sessionId)) return;
174
+ await cdp.send("Network.enable", { maxPostDataSize: 65536 }, sessionId);
175
+ networkEnabledSessions.add(sessionId);
176
+ }
177
+ async function ensureNetSession(cdp, sessionId) {
178
+ await enableNetworkTracking(cdp, sessionId);
179
+ if ((interceptRules.length > 0 || authCredentials) && !fetchEnabledSessions.has(sessionId)) {
180
+ await syncFetch(cdp, sessionId);
181
+ }
182
+ }
183
+ async function syncFetchAll(cdp, currentSessionId) {
184
+ const allSessions = /* @__PURE__ */ new Set([...fetchEnabledSessions, ...networkEnabledSessions]);
185
+ if (currentSessionId) allSessions.add(currentSessionId);
186
+ for (const sid of allSessions) {
187
+ await syncFetch(cdp, sid);
188
+ }
189
+ }
190
+ async function main() {
191
+ const cdp = new CDPClient();
192
+ await cdp.connect(wsUrl);
193
+ let activeSessionId;
194
+ cdp.on("Page.javascriptDialogOpening", (params, sessionId) => {
195
+ handledDialogs.push({ url: params.url, message: params.message, type: params.type, defaultPrompt: params.defaultPrompt || "", sessionId, timestamp: Date.now() });
196
+ if (handledDialogs.length > 20) handledDialogs.shift();
197
+ cdp.send("Page.handleJavaScriptDialog", { accept: true }, sessionId).catch(() => {
198
+ });
199
+ });
200
+ await cdp.send("Target.setDiscoverTargets", { discover: true });
201
+ cdp.on("Target.targetCreated", (params) => {
202
+ const { targetInfo } = params;
203
+ if (targetInfo.type === "page" && targetInfo.openerId) {
204
+ discoveredTargets.push({ targetId: targetInfo.targetId, url: targetInfo.url || "about:blank", openerTargetId: targetInfo.openerId, timestamp: Date.now() });
205
+ if (discoveredTargets.length > 50) discoveredTargets.shift();
206
+ }
207
+ });
208
+ cdp.on("Target.targetInfoChanged", (params) => {
209
+ const e = discoveredTargets.find((d) => d.targetId === params.targetInfo.targetId);
210
+ if (e) e.url = params.targetInfo.url;
211
+ });
212
+ cdp.on("Fetch.authRequired", (params, sessionId) => {
213
+ const resp = authCredentials ? { response: "ProvideCredentials", username: authCredentials.username, password: authCredentials.password } : { response: "CancelAuth" };
214
+ cdp.send("Fetch.continueWithAuth", { requestId: params.requestId, authChallengeResponse: resp }, sessionId).catch(() => {
215
+ });
216
+ });
217
+ cdp.on("Fetch.requestPaused", (params, sessionId) => {
218
+ try {
219
+ const url = params.request?.url || "";
220
+ for (const rule of interceptRules) {
221
+ if (!wildcardMatch(url, rule.pattern)) continue;
222
+ if (rule.type === "block") {
223
+ cdp.send("Fetch.failRequest", { requestId: params.requestId, reason: "BlockedByClient" }, sessionId).catch(() => {
224
+ });
225
+ return;
226
+ }
227
+ if (rule.type === "mock") {
228
+ cdp.send("Fetch.fulfillRequest", { requestId: params.requestId, responseCode: rule.status, responseHeaders: rule.headers, body: rule.body }, sessionId).catch(() => {
229
+ });
230
+ return;
231
+ }
232
+ if (rule.type === "headers" && Array.isArray(rule.headers)) {
233
+ const existing = Object.entries(params.request?.headers || {}).map(([name, value]) => ({ name, value }));
234
+ const overrides = new Set(rule.headers.map((h) => h.name.toLowerCase()));
235
+ const merged = existing.filter((h) => !overrides.has(h.name.toLowerCase()));
236
+ merged.push(...rule.headers);
237
+ cdp.send("Fetch.continueRequest", { requestId: params.requestId, headers: merged }, sessionId).catch(() => {
238
+ });
239
+ return;
240
+ }
241
+ }
242
+ cdp.send("Fetch.continueRequest", { requestId: params.requestId }, sessionId).catch(() => {
243
+ });
244
+ } catch {
245
+ cdp.send("Fetch.continueRequest", { requestId: params.requestId }, sessionId).catch(() => {
246
+ });
247
+ }
248
+ });
249
+ cdp.on("Network.requestWillBeSent", (params, sessionId) => {
250
+ const entry = {
251
+ id: nextReqId++,
252
+ networkId: params.requestId,
253
+ sessionId,
254
+ method: params.request.method,
255
+ url: params.request.url,
256
+ type: params.type || "Other",
257
+ requestHeaders: params.request.headers || {},
258
+ postData: params.request.postData,
259
+ startTime: Date.now(),
260
+ bodyAvailable: false
261
+ };
262
+ trackedRequests.push(entry);
263
+ requestsByNetworkId.set(params.requestId, entry);
264
+ if (trackedRequests.length > MAX_TRACKED) {
265
+ const old = trackedRequests.shift();
266
+ requestsByNetworkId.delete(old.networkId);
267
+ }
268
+ });
269
+ cdp.on("Network.responseReceived", (params) => {
270
+ const e = requestsByNetworkId.get(params.requestId);
271
+ if (e) {
272
+ e.status = params.response.status;
273
+ e.statusText = params.response.statusText;
274
+ e.responseHeaders = params.response.headers;
275
+ e.mimeType = params.response.mimeType;
276
+ }
277
+ });
278
+ cdp.on("Network.loadingFinished", (params) => {
279
+ const e = requestsByNetworkId.get(params.requestId);
280
+ if (e) {
281
+ e.size = params.encodedDataLength;
282
+ e.endTime = Date.now();
283
+ e.bodyAvailable = true;
284
+ }
285
+ });
286
+ cdp.on("Network.loadingFailed", (params) => {
287
+ const e = requestsByNetworkId.get(params.requestId);
288
+ if (e) {
289
+ e.error = params.errorText;
290
+ e.endTime = Date.now();
291
+ }
292
+ });
293
+ const server = http.createServer(async (req, res) => {
294
+ res.setHeader("Content-Type", "application/json");
295
+ let url;
296
+ try {
297
+ url = new URL(req.url || "/", "http://localhost");
298
+ } catch {
299
+ res.writeHead(400);
300
+ res.end(JSON.stringify({ error: "Invalid URL" }));
301
+ return;
302
+ }
303
+ try {
304
+ if (req.method === "GET" && url.pathname === "/health") {
305
+ res.writeHead(200);
306
+ res.end(JSON.stringify({ ok: true, wsUrl }));
307
+ return;
308
+ }
309
+ if (req.method === "POST" && url.pathname === "/cdp") {
310
+ const body = await readBody(req);
311
+ const { method, params, sessionId } = JSON.parse(body);
312
+ if (sessionId) activeSessionId = sessionId;
313
+ const result = await cdp.send(method, params, sessionId);
314
+ res.writeHead(200);
315
+ res.end(JSON.stringify({ result }));
316
+ return;
317
+ }
318
+ if (req.method === "GET" && url.pathname === "/dialogs") {
319
+ res.writeHead(200);
320
+ res.end(JSON.stringify({ dialogs: handledDialogs }));
321
+ return;
322
+ }
323
+ if (req.method === "POST" && url.pathname === "/auth") {
324
+ const body = await readBody(req);
325
+ const { username, password } = JSON.parse(body);
326
+ authCredentials = username ? { username, password } : null;
327
+ await syncFetchAll(cdp, activeSessionId);
328
+ res.writeHead(200);
329
+ res.end(JSON.stringify({ ok: true }));
330
+ return;
331
+ }
332
+ if (req.method === "GET" && url.pathname === "/discovered") {
333
+ res.writeHead(200);
334
+ res.end(JSON.stringify({ targets: discoveredTargets }));
335
+ return;
336
+ }
337
+ if (req.method === "POST" && url.pathname === "/shutdown") {
338
+ res.writeHead(200);
339
+ res.end(JSON.stringify({ ok: true }));
340
+ setTimeout(() => {
341
+ server.close();
342
+ cdp.close();
343
+ cleanup();
344
+ process.exit(0);
345
+ }, 50);
346
+ return;
347
+ }
348
+ if (req.method === "POST" && url.pathname === "/net/enable") {
349
+ const body = await readBody(req);
350
+ const { sessionId } = JSON.parse(body);
351
+ if (!sessionId) {
352
+ res.writeHead(400);
353
+ res.end(JSON.stringify({ error: "sessionId required" }));
354
+ return;
355
+ }
356
+ activeSessionId = sessionId;
357
+ await ensureNetSession(cdp, sessionId);
358
+ res.writeHead(200);
359
+ res.end(JSON.stringify({ ok: true }));
360
+ return;
361
+ }
362
+ if (req.method === "GET" && url.pathname === "/net/requests") {
363
+ const limit = Math.max(1, parseInt(url.searchParams.get("limit") || "20", 10) || 20);
364
+ const urlF = url.searchParams.get("url");
365
+ const methodF = url.searchParams.get("method")?.toUpperCase();
366
+ const statusF = url.searchParams.get("status");
367
+ const typeF = url.searchParams.get("type")?.split(",").map((t) => t.trim().toLowerCase());
368
+ const afterId = Math.max(0, parseInt(url.searchParams.get("after") || "0", 10) || 0);
369
+ let results = trackedRequests.slice();
370
+ if (afterId > 0) results = results.filter((r) => r.id > afterId);
371
+ if (urlF) results = results.filter((r) => wildcardMatch(r.url, urlF));
372
+ if (methodF) results = results.filter((r) => r.method === methodF);
373
+ if (statusF) {
374
+ if (statusF.endsWith("xx")) {
375
+ const p = parseInt(statusF[0], 10);
376
+ results = results.filter((r) => r.status && Math.floor(r.status / 100) === p);
377
+ } else {
378
+ const c = parseInt(statusF, 10);
379
+ if (!isNaN(c)) results = results.filter((r) => r.status === c);
380
+ }
381
+ }
382
+ if (typeF) results = results.filter((r) => typeF.includes(r.type.toLowerCase()));
383
+ const sliced = results.slice(-limit);
384
+ res.writeHead(200);
385
+ res.end(JSON.stringify({ requests: sliced.map((r) => ({ id: r.id, method: r.method, url: r.url, status: r.status, type: r.type, size: r.size, time: r.endTime && r.startTime ? r.endTime - r.startTime : null, error: r.error })), total: trackedRequests.length }));
386
+ return;
387
+ }
388
+ if (req.method === "GET" && url.pathname.startsWith("/net/request/")) {
389
+ const id = parseInt(url.pathname.split("/").pop(), 10);
390
+ if (isNaN(id)) {
391
+ res.writeHead(400);
392
+ res.end(JSON.stringify({ error: "Invalid request ID" }));
393
+ return;
394
+ }
395
+ const entry = trackedRequests.find((r) => r.id === id);
396
+ if (!entry) {
397
+ res.writeHead(404);
398
+ res.end(JSON.stringify({ error: "Request not found" }));
399
+ return;
400
+ }
401
+ res.writeHead(200);
402
+ res.end(JSON.stringify(entry));
403
+ return;
404
+ }
405
+ if (req.method === "GET" && url.pathname.startsWith("/net/body/")) {
406
+ const id = parseInt(url.pathname.split("/").pop(), 10);
407
+ if (isNaN(id)) {
408
+ res.writeHead(400);
409
+ res.end(JSON.stringify({ error: "Invalid request ID" }));
410
+ return;
411
+ }
412
+ const entry = trackedRequests.find((r) => r.id === id);
413
+ if (!entry) {
414
+ res.writeHead(404);
415
+ res.end(JSON.stringify({ error: "Request not found" }));
416
+ return;
417
+ }
418
+ if (!entry.bodyAvailable) {
419
+ res.writeHead(400);
420
+ res.end(JSON.stringify({ error: "Body not available" }));
421
+ return;
422
+ }
423
+ const sid = entry.sessionId || activeSessionId;
424
+ const result = await cdp.send("Network.getResponseBody", { requestId: entry.networkId }, sid);
425
+ const body = result.base64Encoded ? Buffer.from(result.body, "base64").toString("utf-8") : result.body;
426
+ res.writeHead(200);
427
+ res.end(JSON.stringify({ id: entry.id, body, mimeType: entry.mimeType }));
428
+ return;
429
+ }
430
+ if (req.method === "POST" && url.pathname === "/net/clear") {
431
+ trackedRequests.length = 0;
432
+ requestsByNetworkId.clear();
433
+ nextReqId = 1;
434
+ res.writeHead(200);
435
+ res.end(JSON.stringify({ ok: true }));
436
+ return;
437
+ }
438
+ if (req.method === "POST" && url.pathname === "/net/rules") {
439
+ const b = JSON.parse(await readBody(req));
440
+ if (!b.pattern || typeof b.pattern !== "string") {
441
+ res.writeHead(400);
442
+ res.end(JSON.stringify({ error: "pattern is required" }));
443
+ return;
444
+ }
445
+ let rule;
446
+ if (b.type === "block") {
447
+ rule = { id: nextRuleId++, type: "block", pattern: b.pattern };
448
+ } else if (b.type === "mock") {
449
+ const content = b.body || "";
450
+ rule = { id: nextRuleId++, type: "mock", pattern: b.pattern, status: b.status || 200, headers: Array.isArray(b.headers) ? b.headers : [{ name: "Content-Type", value: "application/json" }], body: Buffer.from(content).toString("base64") };
451
+ } else if (b.type === "headers") {
452
+ if (!Array.isArray(b.headers)) {
453
+ res.writeHead(400);
454
+ res.end(JSON.stringify({ error: "headers array required" }));
455
+ return;
456
+ }
457
+ rule = { id: nextRuleId++, type: "headers", pattern: b.pattern, headers: b.headers };
458
+ } else {
459
+ res.writeHead(400);
460
+ res.end(JSON.stringify({ error: `Unknown rule type: ${b.type}` }));
461
+ return;
462
+ }
463
+ interceptRules.push(rule);
464
+ await syncFetchAll(cdp, activeSessionId);
465
+ res.writeHead(200);
466
+ res.end(JSON.stringify({ ok: true, rule }));
467
+ return;
468
+ }
469
+ if (req.method === "GET" && url.pathname === "/net/rules") {
470
+ res.writeHead(200);
471
+ res.end(JSON.stringify({ rules: interceptRules }));
472
+ return;
473
+ }
474
+ if (req.method === "POST" && url.pathname === "/net/rules/remove") {
475
+ const b = JSON.parse(await readBody(req));
476
+ if (b.all) {
477
+ interceptRules.length = 0;
478
+ } else if (typeof b.id === "number") {
479
+ const idx = interceptRules.findIndex((r) => r.id === b.id);
480
+ if (idx >= 0) interceptRules.splice(idx, 1);
481
+ }
482
+ await syncFetchAll(cdp, activeSessionId);
483
+ res.writeHead(200);
484
+ res.end(JSON.stringify({ ok: true }));
485
+ return;
486
+ }
487
+ res.writeHead(404);
488
+ res.end(JSON.stringify({ error: "Not found" }));
489
+ } catch (err) {
490
+ res.writeHead(500);
491
+ res.end(JSON.stringify({ error: err.message }));
492
+ }
493
+ });
494
+ server.listen(SOCKET_PATH, () => {
495
+ try {
496
+ chmodSync(SOCKET_PATH, 384);
497
+ } catch {
498
+ }
499
+ writeFileSync(PID_FILE, String(process.pid), { mode: 384 });
500
+ });
501
+ process.on("SIGTERM", () => {
502
+ cdp.close();
503
+ cleanup();
504
+ process.exit(0);
505
+ });
506
+ process.on("SIGINT", () => {
507
+ cdp.close();
508
+ cleanup();
509
+ process.exit(0);
510
+ });
511
+ }
512
+ main().catch((err) => {
513
+ process.stderr.write(`Daemon error: ${err.message}
514
+ `);
515
+ process.exit(1);
516
+ });