Skip to content

Commit 6d68ffd

Browse files
six-ddcclaude
andcommitted
feat(network): replace requests/clear with CDP-based watch/unwatch
Replace the webRequest-based `network requests` and `network clear` commands with a CDP-based `network watch`/`unwatch` system that uses chrome.debugger to capture full request/response details including headers and bodies, writing results to files in ~/.browser-cli/watches/. - Add WatchManager (daemon) for watch lifecycle and HTTP-readable output - Add network-watcher.ts (extension) using CDP Network domain events - Update debugger-input.ts to reuse debugger session during active watch - Migrate youtube.mjs transcript extraction to use network watch - Update SKILL.md, NETWORK_REFERENCE.md, site-guide, and all docs - Add schema tests for networkWatch/networkUnwatch - Clean up graceful shutdown to stop active watches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 097d73b commit 6d68ffd

File tree

24 files changed

+1092
-391
lines changed

24 files changed

+1092
-391
lines changed

apps/cli/src/commands/network.ts

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Command } from 'commander';
22
import { sendCommand } from './shared.js';
33

44
const networkCmd = new Command('network').description(
5-
'Network interception — block/redirect/track requests (subcommands: route, unroute, routes, requests, clear)',
5+
'Network interception — block/redirect/watch requests (subcommands: route, unroute, routes, watch, unwatch)',
66
);
77

88
networkCmd
@@ -88,76 +88,64 @@ networkCmd
8888
});
8989

9090
networkCmd
91-
.command('requests')
92-
.description('List tracked network requests')
93-
.option('--pattern <pattern>', 'Filter by URL pattern')
94-
.option('--tab <tabId>', 'Filter by tab ID')
95-
.option('--blocked', 'Only show blocked/redirected requests')
96-
.option('--limit <n>', 'Limit number of results', '50')
91+
.command('watch [pattern]')
92+
.description('Monitor API requests/responses via CDP (non-blocking, writes to file)')
93+
.option('--timeout <ms>', 'Auto-stop after ms', '30000')
94+
.option('--body', 'Capture response bodies (skips binary)')
95+
.option('--method <method>', 'Filter by HTTP method')
9796
.action(
9897
async (
99-
opts: { pattern?: string; tab?: string; blocked?: boolean; limit: string },
98+
pattern: string | undefined,
99+
opts: { timeout: string; body?: boolean; method?: string },
100100
cmd: Command,
101101
) => {
102102
const result = await sendCommand(cmd, {
103-
action: 'getRequests',
103+
action: 'networkWatch',
104104
params: {
105-
pattern: opts.pattern,
106-
tabId: opts.tab ? parseInt(opts.tab, 10) : undefined,
107-
blockedOnly: opts.blocked,
108-
limit: parseInt(opts.limit, 10),
105+
pattern,
106+
timeout: parseInt(opts.timeout, 10),
107+
body: opts.body,
108+
method: opts.method,
109109
},
110110
});
111111

112112
if (result) {
113-
const data = result as {
114-
requests: Array<{
115-
id: string;
116-
url: string;
117-
method: string;
118-
type: string;
119-
timestamp: number;
120-
tabId: number;
121-
blocked?: boolean;
122-
redirectedTo?: string;
123-
}>;
124-
total: number;
113+
const r = result as {
114+
watchId: string;
115+
tabId: number;
116+
pattern: string;
117+
timeout: number;
118+
filePath: string;
125119
};
126-
const { requests, total } = data;
127-
128-
if (requests.length === 0) {
129-
console.log('(no requests)');
130-
return;
131-
}
132-
133-
console.log(`Showing ${requests.length} of ${total} requests:\n`);
134-
135-
for (const req of requests) {
136-
const status = req.blocked
137-
? '[BLOCKED]'
138-
: req.redirectedTo
139-
? `[REDIRECT → ${req.redirectedTo}]`
140-
: '';
141-
const age = Math.floor((Date.now() - req.timestamp) / 1000);
142-
console.log(`${req.method} ${req.url}`);
143-
console.log(` Type: ${req.type} Tab: ${req.tabId} ${status} (${age}s ago)`);
144-
}
120+
const timeoutSec = Math.round(r.timeout / 1000);
121+
console.log(
122+
`Watching network requests matching "${r.pattern}" for ${timeoutSec}s (tab ${r.tabId})`,
123+
);
124+
console.log(`Results → ${r.filePath}`);
145125
}
146126
},
147127
);
148128

149129
networkCmd
150-
.command('clear')
151-
.description('Clear all tracked requests')
130+
.command('unwatch')
131+
.description('Stop an active network watch (use --tab to target specific tab)')
152132
.action(async (opts: unknown, cmd: Command) => {
153133
const result = await sendCommand(cmd, {
154-
action: 'clearRequests',
134+
action: 'networkUnwatch',
155135
params: {},
156136
});
157137

158138
if (result) {
159-
const cleared = (result as { cleared: number }).cleared;
160-
console.log(`Cleared ${cleared} requests`);
139+
const r = result as {
140+
watchId: string;
141+
requestCount: number;
142+
duration: number;
143+
filePath: string;
144+
};
145+
console.log(
146+
`Watch ${r.watchId} stopped — ${r.requestCount} requests captured in ${r.duration}s`,
147+
);
148+
console.log(`Results → ${r.filePath}`);
161149
}
162150
});
163151

apps/cli/src/daemon/bridge.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { COMMAND_TIMEOUT_MS } from '@browser-cli/shared';
22
import type { DaemonRequest, DaemonResponse, RequestMessage } from '@browser-cli/shared';
33
import type { WsServer } from './ws-server.js';
4+
import type { WatchManager } from './watch-manager.js';
45
import { logger } from '../util/logger.js';
56

67
/**
@@ -9,8 +10,25 @@ import { logger } from '../util/logger.js';
910
* and converts ResponseMessage → DaemonResponse.
1011
*/
1112
export class Bridge {
13+
private watchManager: WatchManager | null = null;
14+
1215
constructor(private wsServer: WsServer) {}
1316

17+
/** Inject WatchManager (called from daemon/index.ts after construction) */
18+
setWatchManager(wm: WatchManager): void {
19+
this.watchManager = wm;
20+
}
21+
22+
/** Helper to send a request to the extension via WsServer */
23+
private sendToExtension(msg: RequestMessage, sessionId?: string): Promise<unknown> {
24+
return this.wsServer.sendRequest(msg, COMMAND_TIMEOUT_MS, sessionId).then((resp) => {
25+
if (!resp.success) {
26+
throw new Error(resp.error?.message || 'Extension command failed');
27+
}
28+
return resp.data;
29+
});
30+
}
31+
1432
async handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
1533
// If a specific session is requested, check that connection
1634
if (req.sessionId) {
@@ -36,6 +54,37 @@ export class Bridge {
3654
};
3755
}
3856

57+
// Intercept networkWatch / networkUnwatch — handled by WatchManager
58+
if (req.command.action === 'networkWatch' && this.watchManager) {
59+
try {
60+
const params = req.command.params as {
61+
pattern?: string;
62+
timeout?: number;
63+
body?: boolean;
64+
method?: string;
65+
};
66+
const result = await this.watchManager.startWatch(req.tabId ?? 0, params, (msg) =>
67+
this.sendToExtension(msg, req.sessionId),
68+
);
69+
return { id: req.id, success: true, data: result };
70+
} catch (err) {
71+
const msg = err instanceof Error ? err.message : String(err);
72+
return { id: req.id, success: false, error: { message: msg } };
73+
}
74+
}
75+
76+
if (req.command.action === 'networkUnwatch' && this.watchManager) {
77+
try {
78+
const result = await this.watchManager.stopWatch(req.tabId, (msg) =>
79+
this.sendToExtension(msg, req.sessionId),
80+
);
81+
return { id: req.id, success: true, data: result };
82+
} catch (err) {
83+
const msg = err instanceof Error ? err.message : String(err);
84+
return { id: req.id, success: false, error: { message: msg } };
85+
}
86+
}
87+
3988
const wsRequest: RequestMessage = {
4089
id: req.id,
4190
type: 'request',

apps/cli/src/daemon/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DEFAULT_WS_PORT, DEFAULT_WS_HOST } from '@browser-cli/shared';
99
import { WsServer } from './ws-server.js';
1010
import { SocketServer } from './socket-server.js';
1111
import { Bridge } from './bridge.js';
12+
import { WatchManager } from './watch-manager.js';
1213
import { writeDaemonPid, cleanupPidFile } from './process.js';
1314
import { isNonLoopback, generateAuthToken, writeAuthToken, cleanupAuthToken } from './auth.js';
1415
import { getSocketPath, getAppDir } from '../util/paths.js';
@@ -76,6 +77,8 @@ async function main() {
7677
authToken,
7778
});
7879
const bridge = new Bridge(wsServer);
80+
const watchManager = new WatchManager(wsServer);
81+
bridge.setWatchManager(watchManager);
7982
const socketServer = new SocketServer(
8083
(req) => bridge.handleRequest(req),
8184
() => ({
@@ -100,6 +103,7 @@ async function main() {
100103
// Graceful shutdown
101104
const shutdown = async () => {
102105
logger.info('Shutting down daemon...');
106+
await watchManager.stopAll((msg) => wsServer.sendRequest(msg, 5000).then((resp) => resp.data));
103107
await socketServer.stop();
104108
await wsServer.stop();
105109
cleanupPidFile();

0 commit comments

Comments
 (0)