@glittering0717/ai-browser 0.0.0-stage → 1.2.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.
@@ -0,0 +1,805 @@
1
+ // main/network_monitor.js — 有界的 Network 请求日志(可查询的 Chrome Network 面板)
2
+ //
3
+ // 设计要点(方案 docs/PLAN_CANVAS_NETWORK.md 第二章):
4
+ // 1. 只记内存,不落盘;记录数与 body 字节数双重上限,超出淘汰最旧的,累计
5
+ // dropped_records。长会话内存必须收敛,不能随请求数无限增长。
6
+ // 2. 请求侧(method / headers / POST body / initiator)与响应侧都抓,POST 请求体
7
+ // 是用户明确要拿的东西 —— 之前只有 URL 子串猜响应体。
8
+ // 3. 拿不到就用明确 state(evicted / unavailable / partial),绝不静默 null。
9
+ // 4. 敏感头默认脱敏。
10
+ //
11
+ // 这个模块不依赖 Electron:CDP 事件由 page_manager 喂进来,body 抓取通过一个
12
+ // 注入的 fetcher(tabId -> (requestId, kind) => body),因此可在纯 Node 下单测。
13
+
14
+ const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']);
15
+ const REDACTED = '[REDACTED]';
16
+ const SENSITIVE_WARNING = 'request/response data may contain credentials or personal data';
17
+
18
+ const API_RESOURCE_TYPES = new Set(['Document', 'XHR', 'Fetch', 'WebSocket']);
19
+ const TEXT_MIME_RE = /^(text\/|application\/(json|xml|x-www-form-urlencoded|javascript|graphql)|.*\+json$|.*\+xml$)/i;
20
+
21
+ // 稳定错误码(方案 2.4)。调用方能靠 code 区分"不存在/被淘汰/拿不到/敏感头未开"。
22
+ export const NETWORK_ERROR_CODES = {
23
+ NETWORK_NOT_FOUND: -32020,
24
+ NETWORK_BODY_EVICTED: -32021,
25
+ NETWORK_BODY_UNAVAILABLE: -32022,
26
+ SENSITIVE_HEADERS_DISABLED: -32023,
27
+ };
28
+
29
+ export class NetworkError extends Error {
30
+ constructor(code, message, data) {
31
+ super(message);
32
+ this.name = 'NetworkError';
33
+ this.rpcCode = code;
34
+ this.data = data || null;
35
+ }
36
+ }
37
+
38
+ function envInt(name, fallback) {
39
+ const n = Number(process.env[name]);
40
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
41
+ }
42
+ function envFlag(name, fallback) {
43
+ const v = process.env[name];
44
+ if (v === undefined || v === '') return fallback;
45
+ return !(v === '0' || v === 'false' || v === 'no');
46
+ }
47
+
48
+ export function defaultLimits() {
49
+ return {
50
+ // 记录数上限(全局)。超出淘汰最旧的并累计 dropped_records。
51
+ maxRecords: envInt('AI_BROWSER_NETWORK_MAX_RECORDS', 1000),
52
+ // body 总字节上限(全局)。超出优先淘汰最旧的 response body。
53
+ maxBodyBytes: envInt('AI_BROWSER_NETWORK_MAX_BODY_BYTES', 32 * 1024 * 1024),
54
+ // 单条 body 抓取上限。超过只存前缀并标 partial。
55
+ maxSingleBodyBytes: envInt('AI_BROWSER_NETWORK_MAX_SINGLE_BODY_BYTES', 2 * 1024 * 1024),
56
+ // 单次输出分页:默认 64KiB,最大 256KiB(只是输出分页,不等于采集截断)。
57
+ defaultBodyLimit: envInt('AI_BROWSER_NETWORK_BODY_LIMIT', 65536),
58
+ maxBodyLimit: envInt('AI_BROWSER_NETWORK_BODY_LIMIT_MAX', 262144),
59
+ listLimitDefault: 50,
60
+ listLimitMax: 200,
61
+ };
62
+ }
63
+
64
+ // Network.enable 的 postData 内联上限;拿不到时再走 Network.getRequestPostData。
65
+ export const MAX_POST_DATA_SIZE = envInt('AI_BROWSER_NETWORK_MAX_POST_DATA', 1024 * 1024);
66
+
67
+ function nowIso(ms) {
68
+ try { return new Date(ms).toISOString(); } catch (e) { return new Date().toISOString(); }
69
+ }
70
+
71
+ function codePointLength(s) { return Array.from(s == null ? '' : String(s)).length; }
72
+ function sliceByCodePoints(s, offset, limit) {
73
+ const arr = Array.from(s == null ? '' : String(s));
74
+ const start = Math.max(0, Math.min(arr.length, Number(offset) || 0));
75
+ const end = limit == null ? arr.length : Math.min(arr.length, start + Math.max(0, Math.floor(limit)));
76
+ return arr.slice(start, end).join('');
77
+ }
78
+
79
+ // CDP headers 可能是对象(request.headers / extraInfo.headers)或数组。
80
+ // 统一成 [{name, value}]。
81
+ export function normalizeHeaders(raw) {
82
+ const out = [];
83
+ if (!raw) return out;
84
+ if (Array.isArray(raw)) {
85
+ for (const h of raw) {
86
+ if (!h) continue;
87
+ const name = String(h.name || h[0] || '');
88
+ const value = h.value != null ? String(h.value) : String(h[1] != null ? h[1] : '');
89
+ if (name) out.push({ name, value });
90
+ }
91
+ return out;
92
+ }
93
+ if (typeof raw === 'object') {
94
+ for (const k of Object.keys(raw)) out.push({ name: String(k), value: String(raw[k]) });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // 大小写不敏感合并:后到的覆盖先到的(extraInfo 是浏览器实际发出的头,权威)。
100
+ export function mergeHeaders(base, extra) {
101
+ const map = new Map();
102
+ for (const h of base || []) map.set(String(h.name).toLowerCase(), { name: h.name, value: h.value });
103
+ for (const h of extra || []) map.set(String(h.name).toLowerCase(), { name: h.name, value: h.value });
104
+ return Array.from(map.values());
105
+ }
106
+
107
+ export function redactHeaders(headers, includeSensitive) {
108
+ return (headers || []).map((h) => {
109
+ const isSensitive = SENSITIVE_HEADERS.has(String(h.name).toLowerCase());
110
+ if (isSensitive && !includeSensitive) return { name: h.name, value: REDACTED, redacted: true };
111
+ return { name: h.name, value: h.value };
112
+ });
113
+ }
114
+
115
+ export function hasSensitiveHeader(headers) {
116
+ return (headers || []).some((h) => SENSITIVE_HEADERS.has(String(h.name).toLowerCase()));
117
+ }
118
+
119
+ function bodyKind(mimeType, explicit) {
120
+ if (explicit) return explicit;
121
+ const m = String(mimeType || '').toLowerCase().split(';')[0].trim();
122
+ if (!m) return 'unknown';
123
+ if (m === 'application/json' || /\+json$/.test(m)) return 'json';
124
+ if (m === 'application/x-www-form-urlencoded') return 'form_urlencoded';
125
+ if (m === 'multipart/form-data') return 'multipart';
126
+ if (/^text\//.test(m) || m === 'application/xml' || /\+xml$/.test(m)) return 'text';
127
+ return 'binary';
128
+ }
129
+
130
+ function safeDecode(s) {
131
+ try { return decodeURIComponent(String(s).replace(/\+/g, ' ')); } catch (e) { return String(s); }
132
+ }
133
+
134
+ // 只在完整且可安全解析时提供 parsed。
135
+ function parseBody(text, kind) {
136
+ if (kind === 'json') {
137
+ try { return JSON.parse(text); } catch (e) { return null; }
138
+ }
139
+ if (kind === 'form_urlencoded') {
140
+ const pairs = [];
141
+ for (const part of String(text).split('&')) {
142
+ if (!part) continue;
143
+ const eq = part.indexOf('=');
144
+ const k = eq < 0 ? part : part.slice(0, eq);
145
+ const v = eq < 0 ? '' : part.slice(eq + 1);
146
+ pairs.push({ name: safeDecode(k), value: safeDecode(v) });
147
+ }
148
+ return pairs;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ let seqCounter = 0;
154
+
155
+ export class NetworkMonitor {
156
+ constructor(limits = {}, opts = {}) {
157
+ this.limits = { ...defaultLimits(), ...limits };
158
+ this._records = new Map(); // network_id -> record
159
+ this._order = []; // network_id,按 seq 升序
160
+ this._droppedRecords = 0;
161
+ this._bodyBytes = 0;
162
+ this._startedAt = new Date().toISOString();
163
+ this._fetchers = new Map(); // tabId -> async (requestId, kind) => {data, base64Encoded, complete}
164
+ this._tabConfig = new Map(); // tabId -> { enabled, capture_bodies }
165
+ this.captureEnabled = opts.captureEnabled !== undefined ? opts.captureEnabled : envFlag('AI_BROWSER_NETWORK_CAPTURE', true);
166
+ this.sensitiveAllowed = opts.sensitiveAllowed !== undefined ? opts.sensitiveAllowed : envFlag('AI_BROWSER_NETWORK_SENSITIVE', false);
167
+ this.globalCaptureBodies = 'api';
168
+ }
169
+
170
+ // ==== 配置 ============================================================
171
+
172
+ setBodyFetcher(tabId, fn) { this._fetchers.set(tabId, fn); }
173
+ clearBodyFetcher(tabId) { this._fetchers.delete(tabId); }
174
+
175
+ tabConfig(tabId) {
176
+ const c = this._tabConfig.get(tabId);
177
+ if (c) return c;
178
+ return { enabled: this.captureEnabled, capture_bodies: this.globalCaptureBodies };
179
+ }
180
+
181
+ configure(tabId, opts = {}) {
182
+ const cur = this.tabConfig(tabId);
183
+ const next = {
184
+ enabled: opts.enabled === undefined ? cur.enabled : !!opts.enabled,
185
+ capture_bodies: ['none', 'api', 'all'].indexOf(opts.capture_bodies) >= 0 ? opts.capture_bodies : cur.capture_bodies,
186
+ };
187
+ this._tabConfig.set(tabId, next);
188
+ if (!next.enabled) this.clear(tabId);
189
+ return next;
190
+ }
191
+
192
+ // ==== 记录生命周期 =====================================================
193
+
194
+ _networkId(tabId, requestId, redirectIndex) {
195
+ return `${tabId}:${requestId}:${redirectIndex || 0}`;
196
+ }
197
+
198
+ _insert(rec) {
199
+ this._records.set(rec.network_id, rec);
200
+ this._order.push(rec.network_id);
201
+ this._enforceRecordLimit(rec.network_id);
202
+ return rec;
203
+ }
204
+
205
+ _enforceRecordLimit(protectId) {
206
+ while (this._records.size > this.limits.maxRecords) {
207
+ let victim = null;
208
+ for (const id of this._order) {
209
+ if (id === protectId) continue;
210
+ victim = id; break;
211
+ }
212
+ if (!victim) break;
213
+ this._dropRecord(victim);
214
+ }
215
+ }
216
+
217
+ _dropRecord(networkId) {
218
+ const rec = this._records.get(networkId);
219
+ if (!rec) return;
220
+ this._bodyBytes -= (rec._reqBodyBytes || 0) + (rec._respBodyBytes || 0);
221
+ if (this._bodyBytes < 0) this._bodyBytes = 0;
222
+ this._records.delete(networkId);
223
+ const i = this._order.indexOf(networkId);
224
+ if (i >= 0) this._order.splice(i, 1);
225
+ this._droppedRecords += 1;
226
+ }
227
+
228
+ // body 超限:优先淘汰最旧的 response body,其次 request body;metadata 保留。
229
+ _enforceBodyLimit(protectId) {
230
+ if (this._bodyBytes <= this.limits.maxBodyBytes) return;
231
+ for (const pass of ['response', 'request']) {
232
+ for (const id of this._order) {
233
+ if (this._bodyBytes <= this.limits.maxBodyBytes) return;
234
+ if (id === protectId) continue;
235
+ const rec = this._records.get(id);
236
+ if (!rec) continue;
237
+ if (pass === 'response' && rec._respBody) {
238
+ this._bodyBytes -= rec._respBodyBytes || 0;
239
+ rec._respBody = null; rec._respBodyBytes = 0;
240
+ rec.response_body_state = 'evicted';
241
+ rec._respEvicted = true;
242
+ } else if (pass === 'request' && rec._reqBody) {
243
+ this._bodyBytes -= rec._reqBodyBytes || 0;
244
+ rec._reqBody = null; rec._reqBodyBytes = 0;
245
+ rec.request_body_state = 'evicted';
246
+ rec._reqEvicted = true;
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ _storeBody(rec, which, data, opts = {}) {
253
+ const encoding = opts.base64Encoded ? 'base64' : 'utf8';
254
+ const raw = data == null ? '' : String(data);
255
+ const cap = this.limits.maxSingleBodyBytes;
256
+ const arr = Array.from(raw);
257
+ const overCap = arr.length > cap;
258
+ const stored = overCap ? arr.slice(0, cap).join('') : raw;
259
+ const omissions = (opts.omissions || []).slice();
260
+ if (overCap) omissions.push('single_body_cap_exceeded');
261
+ const body = {
262
+ encoding,
263
+ data: stored,
264
+ complete: opts.complete !== false,
265
+ omissions,
266
+ captured_length: codePointLength(stored),
267
+ total_length: opts.total_length != null ? opts.total_length : codePointLength(raw),
268
+ };
269
+ // UTF-16 内部存储:一个 code point 粗估 2 字节。
270
+ const bytes = body.captured_length * (encoding === 'base64' ? 1 : 2);
271
+
272
+ if (which === 'request') {
273
+ this._bodyBytes -= rec._reqBodyBytes || 0;
274
+ rec._reqBody = body;
275
+ rec._reqBodyBytes = bytes;
276
+ rec._reqEvicted = false;
277
+ rec.request_body_state = overCap ? 'partial' : 'captured';
278
+ } else {
279
+ this._bodyBytes -= rec._respBodyBytes || 0;
280
+ rec._respBody = body;
281
+ rec._respBodyBytes = bytes;
282
+ rec._respEvicted = false;
283
+ rec.response_body_state = overCap ? 'partial' : 'captured';
284
+ }
285
+ this._bodyBytes += bytes;
286
+ this._enforceBodyLimit(rec.network_id);
287
+ return body;
288
+ }
289
+
290
+ // ==== CDP 事件入口 =====================================================
291
+
292
+ onRequestWillBeSent(tabId, params) {
293
+ if (!this.tabConfig(tabId).enabled) return null;
294
+ const requestId = params.requestId;
295
+ // 重定向:同一个 CDP requestId 复用,先用 redirectResponse 收尾上一跳。
296
+ let redirectIndex = 0;
297
+ if (params.redirectResponse) {
298
+ const prev = this._findByRequestId(tabId, requestId);
299
+ if (prev) {
300
+ this._applyResponse(prev, params.redirectResponse, { fromRedirect: true });
301
+ prev.state = 'finished';
302
+ prev.finished_at = nowIso(Date.now());
303
+ if (prev.response_body_state === 'pending') prev.response_body_state = 'lazy';
304
+ redirectIndex = (prev.redirect_index || 0) + 1;
305
+ }
306
+ }
307
+ const req = params.request || {};
308
+ const rec = {
309
+ seq: ++seqCounter,
310
+ network_id: this._networkId(tabId, requestId, redirectIndex),
311
+ request_id: String(requestId),
312
+ redirect_index: redirectIndex,
313
+ tab_id: tabId,
314
+ frame_id: params.frameId != null ? String(params.frameId) : null,
315
+ loader_id: params.loaderId != null ? String(params.loaderId) : null,
316
+ url: String(req.url || ''),
317
+ method: String(req.method || 'GET').toUpperCase(),
318
+ resource_type: String(params.type || req.resourceType || 'Other'),
319
+ started_at: nowIso(params.wallTime ? params.wallTime * 1000 : Date.now()),
320
+ started_monotonic: typeof params.timestamp === 'number' ? params.timestamp : Date.now() / 1000,
321
+ state: 'pending',
322
+ has_request_body: !!req.hasPostData || !!req.postData,
323
+ request_body_state: (req.hasPostData || req.postData) ? 'pending' : 'none',
324
+ response_body_state: 'pending',
325
+ _headers: normalizeHeaders(req.headers),
326
+ headers_source: 'request',
327
+ initiator: normalizeInitiator(params.initiator),
328
+ document_url: String(params.documentURL || req.url || ''),
329
+ _reqFetchTried: false,
330
+ _respFetchTried: false,
331
+ _reqBodyBytes: 0,
332
+ _respBodyBytes: 0,
333
+ };
334
+ // postData 内联(Network.enable 带 maxPostDataSize 时 Chromium 会给)。
335
+ if (req.postData != null) {
336
+ const ct = headerValue(normalizeHeaders(req.headers), 'content-type');
337
+ this._storeBody(rec, 'request', req.postData, {
338
+ complete: true,
339
+ omissions: bodyKind(ct) === 'multipart' ? ['multipart_file_bytes'] : [],
340
+ });
341
+ rec.request_body_state = this._reqBodyState(rec);
342
+ }
343
+ const inserted = this._insert(rec);
344
+ // 内联缺失但确实有 body:尽快补一次 getRequestPostData(不阻塞事件循环)。
345
+ if (inserted.has_request_body && !inserted._reqBody) this._fetchRequestBody(tabId, inserted);
346
+ return inserted;
347
+ }
348
+
349
+ _reqBodyState(rec) {
350
+ if (!rec.has_request_body) return 'none';
351
+ if (rec._reqEvicted) return 'evicted';
352
+ if (rec._reqBody) return rec.request_body_state === 'evicted' ? 'evicted' : rec.request_body_state;
353
+ if (rec._reqFetchTried) return 'unavailable';
354
+ return 'pending';
355
+ }
356
+
357
+ // 返回 promise 并缓存在记录上:事件里是 fire-and-forget,之后 _readBody
358
+ // 可能立刻被问到,此时要等同一次抓取完成,而不是再发一次或误判 unavailable。
359
+ async _fetchRequestBody(tabId, rec) {
360
+ if (rec._reqFetchPromise) return rec._reqFetchPromise;
361
+ if (rec._reqFetchTried || rec._reqEvicted) return null;
362
+ rec._reqFetchTried = true;
363
+ rec._reqFetchPromise = (async () => {
364
+ const fn = this._fetchers.get(tabId);
365
+ if (!fn) { rec.request_body_state = 'unavailable'; return null; }
366
+ try {
367
+ const r = await fn(rec.request_id, 'request');
368
+ if (r && r.data != null) {
369
+ this._storeBody(rec, 'request', r.data, { complete: r.complete !== false, omissions: r.omissions || [] });
370
+ } else {
371
+ rec.request_body_state = 'unavailable';
372
+ }
373
+ } catch (e) {
374
+ rec.request_body_state = 'unavailable';
375
+ }
376
+ return rec._reqBody || null;
377
+ })();
378
+ return rec._reqFetchPromise;
379
+ }
380
+
381
+ onRequestWillBeSentExtraInfo(tabId, params) {
382
+ if (!this.tabConfig(tabId).enabled) return null;
383
+ const rec = this._findByRequestId(tabId, params.requestId);
384
+ if (!rec) return null;
385
+ rec._headers = mergeHeaders(rec._headers, normalizeHeaders(params.headers));
386
+ rec.headers_source = 'extraInfo';
387
+ if (Array.isArray(params.associatedCookies)) rec._associatedCookies = params.associatedCookies.length;
388
+ return rec;
389
+ }
390
+
391
+ onResponseReceived(tabId, params) {
392
+ if (!this.tabConfig(tabId).enabled) return null;
393
+ const rec = this._findByRequestId(tabId, params.requestId);
394
+ if (!rec) return null;
395
+ this._applyResponse(rec, params.response, { type: params.type });
396
+ return rec;
397
+ }
398
+
399
+ _applyResponse(rec, response) {
400
+ if (!response) return;
401
+ rec.status = typeof response.status === 'number' ? response.status : undefined;
402
+ rec.status_text = response.statusText || undefined;
403
+ rec.mime_type = response.mimeType || undefined;
404
+ rec.protocol = response.protocol || undefined;
405
+ rec.from_disk_cache = !!response.fromDiskCache;
406
+ rec.from_service_worker = !!response.fromServiceWorker;
407
+ rec.remote_ip_address = response.remoteIPAddress || undefined;
408
+ rec.remote_port = typeof response.remotePort === 'number' ? response.remotePort : undefined;
409
+ rec.security_state = response.securityState || undefined;
410
+ rec._respHeaders = mergeHeaders(rec._respHeaders || [], normalizeHeaders(response.headers));
411
+ if (response.headersText != null) rec._respHeadersText = String(response.headersText);
412
+ if (response.timing) rec._timing = response.timing;
413
+ }
414
+
415
+ onResponseReceivedExtraInfo(tabId, params) {
416
+ if (!this.tabConfig(tabId).enabled) return null;
417
+ const rec = this._findByRequestId(tabId, params.requestId);
418
+ if (!rec) return null;
419
+ rec._respHeaders = mergeHeaders(rec._respHeaders || [], normalizeHeaders(params.headers));
420
+ if (params.headersText != null) rec._respHeadersText = String(params.headersText);
421
+ return rec;
422
+ }
423
+
424
+ onDataReceived(tabId, params) {
425
+ if (!this.tabConfig(tabId).enabled) return null;
426
+ const rec = this._findByRequestId(tabId, params.requestId);
427
+ if (!rec) return null;
428
+ rec.decoded_data_length = (rec.decoded_data_length || 0) + (params.dataLength || 0);
429
+ rec.encoded_data_length = (rec.encoded_data_length || 0) + (params.encodedDataLength || 0);
430
+ return rec;
431
+ }
432
+
433
+ onLoadingFinished(tabId, params) {
434
+ if (!this.tabConfig(tabId).enabled) return null;
435
+ const rec = this._findByRequestId(tabId, params.requestId);
436
+ if (!rec) return null;
437
+ rec.state = 'finished';
438
+ rec.finished_at = nowIso(Date.now());
439
+ if (typeof params.encodedDataLength === 'number') rec.encoded_data_length = params.encodedDataLength;
440
+ const startMs = rec.started_monotonic ? rec.started_monotonic * 1000 : null;
441
+ if (startMs && typeof params.timestamp === 'number') {
442
+ rec.duration_ms = Math.max(0, Math.round(params.timestamp * 1000 - startMs));
443
+ }
444
+ if (rec.response_body_state === 'pending') rec.response_body_state = 'lazy';
445
+ if (this._shouldCaptureResponse(rec)) this._fetchResponseBody(tabId, rec);
446
+ return rec;
447
+ }
448
+
449
+ _shouldCaptureResponse(rec) {
450
+ const mode = this.tabConfig(rec.tab_id).capture_bodies;
451
+ if (mode === 'none') return false;
452
+ if (mode === 'all') return true;
453
+ // 'api':只抓 API/文档类,静态资源保持 lazy(首次读详情时再取)。
454
+ return API_RESOURCE_TYPES.has(rec.resource_type) || TEXT_MIME_RE.test(rec.mime_type || '');
455
+ }
456
+
457
+ async _fetchResponseBody(tabId, rec) {
458
+ if (rec._respFetching || rec._respEvicted) return;
459
+ rec._respFetching = true;
460
+ rec._respFetchTried = true;
461
+ const fn = this._fetchers.get(tabId);
462
+ if (!fn) {
463
+ rec._respFetching = false;
464
+ rec.response_body_state = 'unavailable';
465
+ return;
466
+ }
467
+ try {
468
+ const r = await fn(rec.request_id, 'response');
469
+ if (r && r.data != null) {
470
+ this._storeBody(rec, 'response', r.data, { base64Encoded: !!r.base64Encoded, complete: r.complete !== false });
471
+ } else {
472
+ rec.response_body_state = 'unavailable';
473
+ }
474
+ } catch (e) {
475
+ // Chromium 可能已淘汰该 body(导航/CDP buffer/流未结束)—— 明确标出来。
476
+ rec.response_body_state = 'unavailable';
477
+ rec._respError = String((e && e.message) || e).slice(0, 200);
478
+ }
479
+ rec._respFetching = false;
480
+ }
481
+
482
+ onLoadingFailed(tabId, params) {
483
+ if (!this.tabConfig(tabId).enabled) return null;
484
+ const rec = this._findByRequestId(tabId, params.requestId);
485
+ if (!rec) return null;
486
+ rec.state = 'failed';
487
+ rec.finished_at = nowIso(Date.now());
488
+ rec.error = {
489
+ text: params.errorText || '',
490
+ canceled: params.canceled === true,
491
+ blocked_reason: params.blockedReason || undefined,
492
+ };
493
+ // failed 不是 "HTTP 0 的正常响应",不伪造 status。
494
+ rec.response_body_state = 'unavailable';
495
+ return rec;
496
+ }
497
+
498
+ _findByRequestId(tabId, requestId) {
499
+ // 同一 requestId 可能有多跳;取 redirect_index 最大的那条。
500
+ let best = null;
501
+ for (const id of this._order) {
502
+ const rec = this._records.get(id);
503
+ if (!rec || rec.tab_id !== tabId || rec.request_id !== String(requestId)) continue;
504
+ if (!best || (rec.redirect_index || 0) >= (best.redirect_index || 0)) best = rec;
505
+ }
506
+ return best;
507
+ }
508
+
509
+ // ==== 查询 =============================================================
510
+
511
+ stats() {
512
+ let first = null, last = null;
513
+ if (this._order.length) {
514
+ first = this._records.get(this._order[0]).seq;
515
+ last = this._records.get(this._order[this._order.length - 1]).seq;
516
+ }
517
+ return {
518
+ first_seq: first,
519
+ last_seq: last,
520
+ dropped_records: this._droppedRecords,
521
+ records: this._records.size,
522
+ max_records: this.limits.maxRecords,
523
+ body_bytes: this._bodyBytes,
524
+ max_body_bytes: this.limits.maxBodyBytes,
525
+ };
526
+ }
527
+
528
+ captureInfo(tabId) {
529
+ const cfg = this.tabConfig(tabId);
530
+ return {
531
+ enabled: !!cfg.enabled,
532
+ capture_bodies: cfg.capture_bodies,
533
+ since: this._startedAt,
534
+ limits: {
535
+ max_records: this.limits.maxRecords,
536
+ max_body_bytes: this.limits.maxBodyBytes,
537
+ max_single_body_bytes: this.limits.maxSingleBodyBytes,
538
+ body_limit_default: this.limits.defaultBodyLimit,
539
+ body_limit_max: this.limits.maxBodyLimit,
540
+ list_limit_max: this.limits.listLimitMax,
541
+ },
542
+ };
543
+ }
544
+
545
+ list(filter = {}) {
546
+ const tabId = filter.tab;
547
+ const methods = toUpperArray(filter.method);
548
+ const types = toStringArray(filter.resource_type);
549
+ const states = toStringArray(filter.state);
550
+ const urlContains = typeof filter.url_contains === 'string' ? filter.url_contains : null;
551
+ const statusMin = numOrNull(filter.status && filter.status.min);
552
+ const statusMax = numOrNull(filter.status && filter.status.max);
553
+ const startedAfter = timeOrNull(filter.started_after);
554
+ const startedBefore = timeOrNull(filter.started_before);
555
+ const beforeSeq = numOrNull(filter.before_seq);
556
+
557
+ const rows = [];
558
+ for (const id of this._order) {
559
+ const rec = this._records.get(id);
560
+ if (!rec) continue;
561
+ if (tabId !== undefined && tabId !== null && rec.tab_id !== tabId) continue;
562
+ if (beforeSeq !== null && rec.seq >= beforeSeq) continue;
563
+ if (methods && methods.indexOf(rec.method) < 0) continue;
564
+ if (types && types.indexOf(rec.resource_type) < 0) continue;
565
+ if (states && states.indexOf(rec.state) < 0) continue;
566
+ if (urlContains !== null && rec.url.indexOf(urlContains) < 0) continue;
567
+ if (statusMin !== null && !(rec.status != null && rec.status >= statusMin)) continue;
568
+ if (statusMax !== null && !(rec.status != null && rec.status <= statusMax)) continue;
569
+ if (startedAfter !== null && !(rec.started_at && rec.started_at >= startedAfter)) continue;
570
+ if (startedBefore !== null && !(rec.started_at && rec.started_at <= startedBefore)) continue;
571
+ rows.push(rec);
572
+ }
573
+ rows.sort((a, b) => b.seq - a.seq); // 按 seq 倒序
574
+
575
+ const limit = Math.max(1, Math.min(this.limits.listLimitMax, Number(filter.limit) || this.limits.listLimitDefault));
576
+ const hasMore = rows.length > limit;
577
+ const page = hasMore ? rows.slice(0, limit) : rows;
578
+ const nextBeforeSeq = hasMore ? page[page.length - 1].seq : null;
579
+ const capture = this.captureInfo(tabId);
580
+
581
+ return {
582
+ requests: page.map((r) => this._listItem(r)),
583
+ pagination: { returned: page.length, next_before_seq: nextBeforeSeq, has_more: hasMore },
584
+ retention: this.stats(),
585
+ capture,
586
+ // 采集被关闭时明确告知,避免调用方以为"没有请求"。
587
+ ...(capture.enabled ? {} : { warning: 'capture_disabled' }),
588
+ };
589
+ }
590
+
591
+ _listItem(rec) {
592
+ return {
593
+ seq: rec.seq,
594
+ network_id: rec.network_id,
595
+ request_id: rec.request_id,
596
+ tab_id: rec.tab_id,
597
+ url: rec.url,
598
+ method: rec.method,
599
+ status: rec.status != null ? rec.status : undefined,
600
+ status_text: rec.status_text,
601
+ mime_type: rec.mime_type,
602
+ resource_type: rec.resource_type,
603
+ started_at: rec.started_at,
604
+ duration_ms: rec.duration_ms,
605
+ encoded_data_length: rec.encoded_data_length,
606
+ decoded_data_length: rec.decoded_data_length,
607
+ from_disk_cache: rec.from_disk_cache,
608
+ has_request_body: rec.has_request_body,
609
+ request_body_state: this._reqBodyState(rec),
610
+ response_body_state: rec.response_body_state,
611
+ state: rec.state,
612
+ error: rec.error,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * 单条详情。include_*_body 为 true 且 body 不可得时抛稳定错误码(不静默 null)。
618
+ */
619
+ async get(networkId, opts = {}) {
620
+ const rec = this._records.get(String(networkId));
621
+ if (!rec) {
622
+ throw new NetworkError(NETWORK_ERROR_CODES.NETWORK_NOT_FOUND, 'NETWORK_NOT_FOUND: no captured request with this network_id', { network_id: String(networkId) });
623
+ }
624
+ const includeSensitive = opts.include_sensitive_headers === true;
625
+ if (includeSensitive && !this.sensitiveAllowed) {
626
+ throw new NetworkError(
627
+ NETWORK_ERROR_CODES.SENSITIVE_HEADERS_DISABLED,
628
+ 'SENSITIVE_HEADERS_DISABLED: start ai-browser with AI_BROWSER_NETWORK_SENSITIVE=1 to allow raw sensitive headers',
629
+ {}
630
+ );
631
+ }
632
+ const bodyLimit = Math.max(1, Math.min(this.limits.maxBodyLimit, Number(opts.body_limit) || this.limits.defaultBodyLimit));
633
+
634
+ const out = { record: this._listItem(rec), request: null, response: null, sensitive_data_warning: SENSITIVE_WARNING };
635
+
636
+ if (opts.include_request_headers) {
637
+ const reqHeaders = redactHeaders(rec._headers || [], includeSensitive);
638
+ out.request = out.request || {};
639
+ out.request.headers = reqHeaders;
640
+ out.request.headers_source = rec.headers_source;
641
+ out.request.headers_redacted = hasSensitiveHeader(reqHeaders) && !includeSensitive;
642
+ }
643
+ if (opts.include_response_headers) {
644
+ const respHeaders = redactHeaders(rec._respHeaders || [], includeSensitive);
645
+ out.response = out.response || {};
646
+ out.response.headers = respHeaders;
647
+ out.response.headers_text = rec._respHeadersText || null;
648
+ out.response.headers_redacted = hasSensitiveHeader(respHeaders) && !includeSensitive;
649
+ }
650
+ if (opts.include_request_body) {
651
+ out.request = out.request || {};
652
+ out.request.body = await this._readBody(rec, 'request', numOrNull(opts.request_body_offset) || 0, bodyLimit);
653
+ }
654
+ if (opts.include_response_body) {
655
+ out.response = out.response || {};
656
+ out.response.body = await this._readBody(rec, 'response', numOrNull(opts.response_body_offset) || 0, bodyLimit);
657
+ }
658
+
659
+ // initiator / document_url 等只有详情才给(列表不带,避免灌进上下文)。
660
+ out.request = out.request || {};
661
+ out.request.initiator = rec.initiator || null;
662
+ out.request.document_url = rec.document_url || null;
663
+ out.request.frame_id = rec.frame_id;
664
+ out.request.loader_id = rec.loader_id;
665
+ out.request.redirect_index = rec.redirect_index;
666
+ if (out.response) {
667
+ out.response.mime_type = rec.mime_type;
668
+ out.response.protocol = rec.protocol;
669
+ out.response.from_disk_cache = rec.from_disk_cache;
670
+ out.response.from_service_worker = rec.from_service_worker;
671
+ out.response.remote_ip_address = rec.remote_ip_address;
672
+ }
673
+ return out;
674
+ }
675
+
676
+ async _readBody(rec, which, offset, limit) {
677
+ const isReq = which === 'request';
678
+ if (isReq) {
679
+ // pending:先补一次 getRequestPostData(可能事件还没到 / 内联缺失)。
680
+ if (!rec._reqBody && rec.has_request_body) await this._fetchRequestBody(rec.tab_id, rec);
681
+ } else if (!rec._respEvicted && !rec._respBody && (rec.state === 'finished' || rec.state === 'failed')) {
682
+ await this._fetchResponseBody(rec.tab_id, rec);
683
+ }
684
+
685
+ if (isReq && !rec.has_request_body) {
686
+ return { state: 'none', kind: null, raw: null, parsed: null, complete: true, omissions: [] };
687
+ }
688
+ const stored = isReq ? rec._reqBody : rec._respBody;
689
+ const state = isReq ? this._reqBodyState(rec) : rec.response_body_state;
690
+ if (!stored) {
691
+ if (state === 'evicted' || rec._reqEvicted || rec._respEvicted) {
692
+ throw new NetworkError(NETWORK_ERROR_CODES.NETWORK_BODY_EVICTED, 'NETWORK_BODY_EVICTED: body was captured but dropped by the retention limit', { network_id: rec.network_id, side: which });
693
+ }
694
+ throw new NetworkError(
695
+ NETWORK_ERROR_CODES.NETWORK_BODY_UNAVAILABLE,
696
+ 'NETWORK_BODY_UNAVAILABLE: ' + (rec._respError || (rec.state === 'pending' ? 'request has not finished' : 'CDP did not provide this body')),
697
+ { network_id: rec.network_id, side: which, state }
698
+ );
699
+ }
700
+
701
+ const data = sliceByCodePoints(stored.data, offset, limit);
702
+ const returned = codePointLength(data);
703
+ const total = stored.captured_length;
704
+ const hasMore = offset + returned < total;
705
+ const mime = isReq ? headerValue(rec._headers, 'content-type') : rec.mime_type;
706
+ const kind = bodyKind(mime, stored.kind);
707
+ const isFullSlice = offset === 0 && !hasMore;
708
+ const parsed = (!stored.complete || !isFullSlice || kind === 'binary') ? null : parseBody(stored.data, kind);
709
+
710
+ return {
711
+ state: stored.complete ? state : 'partial',
712
+ source: isReq ? 'requestWillBeSent.postData|Network.getRequestPostData' : 'Network.getResponseBody',
713
+ kind,
714
+ mime_type: mime || null,
715
+ raw: {
716
+ encoding: stored.encoding,
717
+ length_unit: stored.encoding === 'base64' ? 'byte' : 'unicode_code_point',
718
+ data,
719
+ offset,
720
+ returned_bytes: returned,
721
+ captured_bytes: total,
722
+ total_bytes: stored.total_length != null ? stored.total_length : total,
723
+ truncated: hasMore,
724
+ next_offset: hasMore ? offset + returned : null,
725
+ complete_available: !hasMore || stored.complete !== false,
726
+ },
727
+ parsed,
728
+ complete: stored.complete !== false,
729
+ omissions: stored.omissions || [],
730
+ };
731
+ }
732
+
733
+ // 兼容旧 ui.network_body:按 url_pattern 选最近完成的匹配项。
734
+ findLatestFinished(tabId, urlPattern) {
735
+ let best = null;
736
+ for (const id of this._order) {
737
+ const rec = this._records.get(id);
738
+ if (!rec || rec.tab_id !== tabId) continue;
739
+ if (rec.state !== 'finished') continue;
740
+ if (String(rec.url).indexOf(String(urlPattern == null ? '' : urlPattern)) < 0) continue;
741
+ if (!best || rec.seq > best.seq) best = rec;
742
+ }
743
+ return best;
744
+ }
745
+
746
+ clear(tabId) {
747
+ let cleared = 0, clearedBytes = 0;
748
+ const keep = [];
749
+ for (const id of this._order) {
750
+ const rec = this._records.get(id);
751
+ if (!rec) continue;
752
+ if (tabId === undefined || tabId === null || rec.tab_id === tabId) {
753
+ cleared += 1;
754
+ clearedBytes += (rec._reqBodyBytes || 0) + (rec._respBodyBytes || 0);
755
+ this._records.delete(id);
756
+ } else keep.push(id);
757
+ }
758
+ this._order = keep;
759
+ this._bodyBytes = Math.max(0, this._bodyBytes - clearedBytes);
760
+ return { cleared_records: cleared, cleared_body_bytes: clearedBytes };
761
+ }
762
+ }
763
+
764
+ function toUpperArray(v) {
765
+ if (v == null) return null;
766
+ const arr = Array.isArray(v) ? v : [v];
767
+ return arr.map((x) => String(x).toUpperCase());
768
+ }
769
+ function toStringArray(v) {
770
+ if (v == null) return null;
771
+ const arr = Array.isArray(v) ? v : [v];
772
+ return arr.map((x) => String(x));
773
+ }
774
+ function numOrNull(v) {
775
+ const n = Number(v);
776
+ return Number.isFinite(n) ? n : null;
777
+ }
778
+ function timeOrNull(v) {
779
+ if (v == null) return null;
780
+ const t = Date.parse(String(v));
781
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
782
+ }
783
+ function headerValue(headers, name) {
784
+ for (const h of headers || []) if (String(h.name).toLowerCase() === name) return h.value;
785
+ return null;
786
+ }
787
+ function normalizeInitiator(init) {
788
+ if (!init) return null;
789
+ const out = { type: init.type || null };
790
+ if (init.stack && Array.isArray(init.stack.callFrames)) {
791
+ out.stack = {
792
+ callFrames: init.stack.callFrames.slice(0, 30).map((f) => ({
793
+ function: f.functionName || '',
794
+ url: f.url || '',
795
+ line: f.lineNumber,
796
+ column: f.columnNumber,
797
+ })),
798
+ };
799
+ }
800
+ if (init.url) out.url = init.url;
801
+ if (init.lineNumber != null) out.line_number = init.lineNumber;
802
+ return out;
803
+ }
804
+
805
+ export default NetworkMonitor;