chrome-ai-bridge 2.3.10 → 2.4.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/build/extension/background.mjs +67 -12
- package/build/extension/manifest.json +1 -1
- package/build/extension/relay-server.ts +39 -5
- package/build/src/extension/relay-server.js +27 -5
- package/build/src/fast-cdp/extension-raw.js +4 -1
- package/build/src/fast-cdp/fast-chat.js +13 -6
- package/build/src/fast-cdp/network-interceptor.js +96 -26
- package/package.json +1 -1
|
@@ -170,6 +170,8 @@ class RelayConnection {
|
|
|
170
170
|
}
|
|
171
171
|
if (message.method === 'reloadExtension') {
|
|
172
172
|
logInfo('reload', 'reloadExtension command received');
|
|
173
|
+
// Set flag so the reloaded service worker skips cooldown
|
|
174
|
+
chrome.storage.local.set({ _reloadTriggered: Date.now() }).catch(() => {});
|
|
173
175
|
// Delay reload to allow response to be sent first
|
|
174
176
|
setTimeout(() => {
|
|
175
177
|
logInfo('reload', 'Calling chrome.runtime.reload()');
|
|
@@ -731,7 +733,7 @@ const DISCOVERY_INTERVAL_MS = {
|
|
|
731
733
|
};
|
|
732
734
|
const FAST_TO_NORMAL_EMPTY_STREAK = 5;
|
|
733
735
|
const NORMAL_TO_IDLE_EMPTY_STREAK = 20;
|
|
734
|
-
const ACTIVE_TO_IDLE_EMPTY_STREAK =
|
|
736
|
+
const ACTIVE_TO_IDLE_EMPTY_STREAK = 10;
|
|
735
737
|
let lastSuccessfulPort = null;
|
|
736
738
|
const lastRelayByPort = new Map();
|
|
737
739
|
|
|
@@ -745,8 +747,23 @@ let emptyDiscoveryStreak = 0;
|
|
|
745
747
|
let keepAliveActive = false;
|
|
746
748
|
|
|
747
749
|
// リロード時クールダウン: 5秒間は「新しいrelay」検出をスキップ
|
|
750
|
+
// ただし reloadExtension コマンド経由のリロード時はスキップしない
|
|
748
751
|
const extensionStartTime = Date.now();
|
|
749
752
|
const COOLDOWN_MS = 5000;
|
|
753
|
+
let cooldownDisabled = false;
|
|
754
|
+
|
|
755
|
+
// Check if this is a reload triggered by reloadExtension command
|
|
756
|
+
chrome.storage.local.get('_reloadTriggered').then(result => {
|
|
757
|
+
if (result._reloadTriggered) {
|
|
758
|
+
const age = Date.now() - result._reloadTriggered;
|
|
759
|
+
if (age < 10000) { // Within 10 seconds of reload trigger
|
|
760
|
+
cooldownDisabled = true;
|
|
761
|
+
logInfo('discovery', 'Cooldown disabled (reloadExtension triggered)', {age});
|
|
762
|
+
}
|
|
763
|
+
// Clear the flag
|
|
764
|
+
chrome.storage.local.remove('_reloadTriggered').catch(() => {});
|
|
765
|
+
}
|
|
766
|
+
}).catch(() => {});
|
|
750
767
|
|
|
751
768
|
// ユーザー操作によるDiscoveryかどうかのフラグ
|
|
752
769
|
// Chrome起動時やService Worker再起動時はfalse、アイコンクリック時のみtrue
|
|
@@ -937,15 +954,15 @@ async function autoConnectRelay(best) {
|
|
|
937
954
|
|
|
938
955
|
// tabUrl があれば、connect.html を開かずに直接接続
|
|
939
956
|
// preferredTabId があれば優先的に使用
|
|
957
|
+
const requestedNewTab = Boolean(best?.data?.newTab);
|
|
940
958
|
let targetTabId;
|
|
941
959
|
try {
|
|
942
960
|
// autoConnectRelay経由の場合はフォーカスしない(active: false)
|
|
943
|
-
//
|
|
944
|
-
// newTab: false に固定 - 自動接続では既存タブを優先してタブスパムを防止
|
|
961
|
+
// newTab: relay の要求を尊重する(MCP サーバーが newTab: true を指定した場合は新規タブ作成を許可)
|
|
945
962
|
targetTabId = await tabShareExtension._resolveTabId(
|
|
946
963
|
tabUrl,
|
|
947
964
|
preferredTabId,
|
|
948
|
-
|
|
965
|
+
requestedNewTab,
|
|
949
966
|
false, // active: false - 自動接続時はタブをフォーカスしない
|
|
950
967
|
);
|
|
951
968
|
} catch (error) {
|
|
@@ -957,8 +974,23 @@ async function autoConnectRelay(best) {
|
|
|
957
974
|
return false;
|
|
958
975
|
}
|
|
959
976
|
if (tabShareExtension._activeConnections?.has(targetTabId)) {
|
|
960
|
-
|
|
961
|
-
|
|
977
|
+
const existingSessionId = tabShareExtension._tabSessionOwners?.get(targetTabId);
|
|
978
|
+
const newSessionId = best?.data?.sessionId;
|
|
979
|
+
if (existingSessionId && newSessionId && existingSessionId !== newSessionId) {
|
|
980
|
+
// Different session wants the same tab — replace the old connection
|
|
981
|
+
logInfo('auto-connect', 'Replacing stale connection with newer session', {
|
|
982
|
+
targetTabId, oldSession: existingSessionId, newSession: newSessionId,
|
|
983
|
+
});
|
|
984
|
+
const oldConn = tabShareExtension._activeConnections.get(targetTabId);
|
|
985
|
+
if (oldConn) {
|
|
986
|
+
oldConn.close('Replaced by newer session');
|
|
987
|
+
tabShareExtension._activeConnections.delete(targetTabId);
|
|
988
|
+
tabShareExtension._tabSessionOwners.delete(targetTabId);
|
|
989
|
+
}
|
|
990
|
+
} else {
|
|
991
|
+
logInfo('auto-connect', 'Tab already connected', {targetTabId});
|
|
992
|
+
return true; // 同じセッションで接続済み
|
|
993
|
+
}
|
|
962
994
|
}
|
|
963
995
|
|
|
964
996
|
const targetTab = await chrome.tabs.get(targetTabId).catch(() => null);
|
|
@@ -1009,8 +1041,9 @@ async function autoOpenConnectUi() {
|
|
|
1009
1041
|
};
|
|
1010
1042
|
|
|
1011
1043
|
// リロード直後はタブを開かない(既存MCPサーバーとの再接続を防ぐ)
|
|
1044
|
+
// ただし reloadExtension コマンド経由の場合はスキップしない
|
|
1012
1045
|
const elapsed = Date.now() - extensionStartTime;
|
|
1013
|
-
if (elapsed < COOLDOWN_MS) {
|
|
1046
|
+
if (elapsed < COOLDOWN_MS && !cooldownDisabled) {
|
|
1014
1047
|
logDebug('discovery', `Cooldown active (${elapsed}ms < ${COOLDOWN_MS}ms), skipping`);
|
|
1015
1048
|
result.skippedCooldown = true;
|
|
1016
1049
|
return result;
|
|
@@ -1038,7 +1071,7 @@ async function autoOpenConnectUi() {
|
|
|
1038
1071
|
continue;
|
|
1039
1072
|
}
|
|
1040
1073
|
|
|
1041
|
-
logInfo('discovery', 'New relay detected', {port, tabUrl: data.tabUrl, wsUrl: data.wsUrl});
|
|
1074
|
+
logInfo('discovery', 'New relay detected', {port, tabUrl: data.tabUrl, wsUrl: data.wsUrl, startedAt});
|
|
1042
1075
|
lastRelayByPort.set(port, {
|
|
1043
1076
|
wsUrl: data.wsUrl,
|
|
1044
1077
|
startedAt,
|
|
@@ -1047,14 +1080,34 @@ async function autoOpenConnectUi() {
|
|
|
1047
1080
|
newRelays.push({port, data});
|
|
1048
1081
|
}
|
|
1049
1082
|
|
|
1050
|
-
|
|
1083
|
+
// Deduplicate: when multiple relays serve the same tabUrl, prefer the newest (by startedAt)
|
|
1084
|
+
// This prevents stale MCP servers from stealing connections from active ones
|
|
1085
|
+
const bestByTabUrl = new Map();
|
|
1086
|
+
for (const relay of newRelays) {
|
|
1087
|
+
const tabUrl = relay.data?.tabUrl || '';
|
|
1088
|
+
const existing = bestByTabUrl.get(tabUrl);
|
|
1089
|
+
const relayStartedAt = relay.data?.startedAt || 0;
|
|
1090
|
+
if (!existing || relayStartedAt > (existing.data?.startedAt || 0)) {
|
|
1091
|
+
bestByTabUrl.set(tabUrl, relay);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const dedupedRelays = [...bestByTabUrl.values()];
|
|
1095
|
+
if (dedupedRelays.length < newRelays.length) {
|
|
1096
|
+
logInfo('discovery', 'Deduped relays by tabUrl (preferring newest)', {
|
|
1097
|
+
before: newRelays.length,
|
|
1098
|
+
after: dedupedRelays.length,
|
|
1099
|
+
dropped: newRelays.filter(r => !dedupedRelays.includes(r)).map(r => ({port: r.port, tabUrl: r.data?.tabUrl, startedAt: r.data?.startedAt})),
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1051
1102
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1103
|
+
result.newRelayCount = dedupedRelays.length;
|
|
1104
|
+
|
|
1105
|
+
if (dedupedRelays.length > 0) {
|
|
1106
|
+
logInfo('discovery', `Processing ${dedupedRelays.length} new relay(s)`);
|
|
1054
1107
|
}
|
|
1055
1108
|
|
|
1056
1109
|
// 全ての新しい relay を処理(並列ではなく順次)
|
|
1057
|
-
for (const relay of
|
|
1110
|
+
for (const relay of dedupedRelays) {
|
|
1058
1111
|
logInfo('discovery', 'Processing relay', {port: relay.port, tabUrl: relay.data.tabUrl});
|
|
1059
1112
|
debugLog('Processing new relay:', relay.port, relay.data.tabUrl);
|
|
1060
1113
|
let ok = false;
|
|
@@ -1259,5 +1312,7 @@ chrome.runtime.onStartup.addListener(() => {
|
|
|
1259
1312
|
scheduleDiscovery();
|
|
1260
1313
|
});
|
|
1261
1314
|
scheduleDiscovery(); // Start immediately
|
|
1315
|
+
// Ensure keepAlive is active from the start to prevent SW termination during discovery
|
|
1316
|
+
ensureKeepAliveAlarm('startup');
|
|
1262
1317
|
|
|
1263
1318
|
logInfo('background', 'Extension loaded (discovery active)');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "chrome-ai-bridge Extension",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.28",
|
|
5
5
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqDLiSB+b/gbnQ4zWRP65jnd27KmzpjJyR1JAQIjCD/dNORzTgk6+G0TjYEDZLIDceKHzGJudqnwq4q9g3T1eJ0SECZNXnaoE00WkgXfAUSQn6cmmXR3aQGFky/zbCmxnkRa0vYupxszlhw0yrlSZrIJd/weWF75Byh0zJfZ84kqDDhaj7TlB5laHICnoSLmPTif4mQcUW9oOKmAJPriPw4CWATKZsrQ4X46djxefSmfbqYfb9rttAqJVst40gO0Gsl6GOGxMHMds5Cl9GELc0dI3Gpobw07hQldZb8TeyilI/SnOaeS3HPtrp+KyEgRu8SgRdlrvuq6DeEZsP+kK7wIDAQAB",
|
|
6
6
|
"description": "Bridge between Chrome tabs and chrome-ai-bridge MCP server",
|
|
7
7
|
"permissions": ["debugger", "activeTab", "tabs", "storage", "alarms"],
|
|
@@ -55,6 +55,12 @@ export class RelayServer extends EventEmitter {
|
|
|
55
55
|
private discoveryServer: http.Server | null = null;
|
|
56
56
|
private discoveryPort: number | null = null;
|
|
57
57
|
private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
58
|
+
private _lastDiscoveryOptions: {
|
|
59
|
+
tabUrl?: string;
|
|
60
|
+
tabId?: number;
|
|
61
|
+
newTab?: boolean;
|
|
62
|
+
allowTabTakeover?: boolean;
|
|
63
|
+
} = {};
|
|
58
64
|
|
|
59
65
|
constructor(options: RelayServerOptions = {}) {
|
|
60
66
|
super();
|
|
@@ -219,6 +225,9 @@ export class RelayServer extends EventEmitter {
|
|
|
219
225
|
this.ready = true;
|
|
220
226
|
debugLog(`[RelayServer] Connection ready for tab ${this.tabId}`);
|
|
221
227
|
this.emit('ready', this.tabId);
|
|
228
|
+
// Release discovery port after WebSocket is established.
|
|
229
|
+
// 1-second grace period lets Extension finish processing the response.
|
|
230
|
+
setTimeout(() => this.stopDiscoveryServer(), 1000);
|
|
222
231
|
break;
|
|
223
232
|
case 'pong':
|
|
224
233
|
debugLog('[RelayServer] Received keep-alive pong');
|
|
@@ -333,6 +342,7 @@ export class RelayServer extends EventEmitter {
|
|
|
333
342
|
newTab?: boolean;
|
|
334
343
|
allowTabTakeover?: boolean;
|
|
335
344
|
} = {}): Promise<number | null> {
|
|
345
|
+
this._lastDiscoveryOptions = options;
|
|
336
346
|
const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
|
|
337
347
|
const wsUrl = this.getConnectionURL();
|
|
338
348
|
|
|
@@ -404,6 +414,34 @@ export class RelayServer extends EventEmitter {
|
|
|
404
414
|
return null;
|
|
405
415
|
}
|
|
406
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Release the discovery HTTP server (port).
|
|
419
|
+
* Called automatically after the Extension WebSocket connects (ready event).
|
|
420
|
+
* The port becomes available for other sessions.
|
|
421
|
+
*/
|
|
422
|
+
stopDiscoveryServer(): void {
|
|
423
|
+
if (this.discoveryServer) {
|
|
424
|
+
this.discoveryServer.close();
|
|
425
|
+
debugLog(`[RelayServer] Discovery server released (port ${this.discoveryPort})`);
|
|
426
|
+
this.discoveryServer = null;
|
|
427
|
+
this.discoveryPort = null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Re-acquire a discovery port (e.g. after WebSocket disconnect for reconnection).
|
|
433
|
+
* Uses the same options as the last startDiscoveryServer() call.
|
|
434
|
+
*/
|
|
435
|
+
async restartDiscoveryServer(options?: {
|
|
436
|
+
tabUrl?: string;
|
|
437
|
+
tabId?: number;
|
|
438
|
+
newTab?: boolean;
|
|
439
|
+
allowTabTakeover?: boolean;
|
|
440
|
+
}): Promise<number | null> {
|
|
441
|
+
this.stopDiscoveryServer();
|
|
442
|
+
return this.startDiscoveryServer(options || this._lastDiscoveryOptions);
|
|
443
|
+
}
|
|
444
|
+
|
|
407
445
|
/**
|
|
408
446
|
* Stop server
|
|
409
447
|
*/
|
|
@@ -425,11 +463,7 @@ export class RelayServer extends EventEmitter {
|
|
|
425
463
|
new Error('RELAY_STOPPED: Relay stopped before request completion'),
|
|
426
464
|
);
|
|
427
465
|
|
|
428
|
-
|
|
429
|
-
this.discoveryServer.close();
|
|
430
|
-
this.discoveryServer = null;
|
|
431
|
-
this.discoveryPort = null;
|
|
432
|
-
}
|
|
466
|
+
this.stopDiscoveryServer();
|
|
433
467
|
|
|
434
468
|
if (this.wss) {
|
|
435
469
|
return new Promise((resolve) => {
|
|
@@ -29,6 +29,7 @@ export class RelayServer extends EventEmitter {
|
|
|
29
29
|
discoveryServer = null;
|
|
30
30
|
discoveryPort = null;
|
|
31
31
|
keepAliveTimer = null;
|
|
32
|
+
_lastDiscoveryOptions = {};
|
|
32
33
|
constructor(options = {}) {
|
|
33
34
|
super();
|
|
34
35
|
this.host = options.host || '127.0.0.1';
|
|
@@ -172,6 +173,9 @@ export class RelayServer extends EventEmitter {
|
|
|
172
173
|
this.ready = true;
|
|
173
174
|
debugLog(`[RelayServer] Connection ready for tab ${this.tabId}`);
|
|
174
175
|
this.emit('ready', this.tabId);
|
|
176
|
+
// Release discovery port after WebSocket is established.
|
|
177
|
+
// 1-second grace period lets Extension finish processing the response.
|
|
178
|
+
setTimeout(() => this.stopDiscoveryServer(), 1000);
|
|
175
179
|
break;
|
|
176
180
|
case 'pong':
|
|
177
181
|
debugLog('[RelayServer] Received keep-alive pong');
|
|
@@ -269,6 +273,7 @@ export class RelayServer extends EventEmitter {
|
|
|
269
273
|
* Extension polls this endpoint when user clicks the extension icon.
|
|
270
274
|
*/
|
|
271
275
|
async startDiscoveryServer(options = {}) {
|
|
276
|
+
this._lastDiscoveryOptions = options;
|
|
272
277
|
const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
|
|
273
278
|
const wsUrl = this.getConnectionURL();
|
|
274
279
|
for (const port of ports) {
|
|
@@ -332,6 +337,27 @@ export class RelayServer extends EventEmitter {
|
|
|
332
337
|
debugLog('[RelayServer] Could not start discovery server on any port');
|
|
333
338
|
return null;
|
|
334
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Release the discovery HTTP server (port).
|
|
342
|
+
* Called automatically after the Extension WebSocket connects (ready event).
|
|
343
|
+
* The port becomes available for other sessions.
|
|
344
|
+
*/
|
|
345
|
+
stopDiscoveryServer() {
|
|
346
|
+
if (this.discoveryServer) {
|
|
347
|
+
this.discoveryServer.close();
|
|
348
|
+
debugLog(`[RelayServer] Discovery server released (port ${this.discoveryPort})`);
|
|
349
|
+
this.discoveryServer = null;
|
|
350
|
+
this.discoveryPort = null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Re-acquire a discovery port (e.g. after WebSocket disconnect for reconnection).
|
|
355
|
+
* Uses the same options as the last startDiscoveryServer() call.
|
|
356
|
+
*/
|
|
357
|
+
async restartDiscoveryServer(options) {
|
|
358
|
+
this.stopDiscoveryServer();
|
|
359
|
+
return this.startDiscoveryServer(options || this._lastDiscoveryOptions);
|
|
360
|
+
}
|
|
335
361
|
/**
|
|
336
362
|
* Stop server
|
|
337
363
|
*/
|
|
@@ -349,11 +375,7 @@ export class RelayServer extends EventEmitter {
|
|
|
349
375
|
this.ready = false;
|
|
350
376
|
this.tabId = null;
|
|
351
377
|
this.rejectPendingRequests(new Error('RELAY_STOPPED: Relay stopped before request completion'));
|
|
352
|
-
|
|
353
|
-
this.discoveryServer.close();
|
|
354
|
-
this.discoveryServer = null;
|
|
355
|
-
this.discoveryPort = null;
|
|
356
|
-
}
|
|
378
|
+
this.stopDiscoveryServer();
|
|
357
379
|
if (this.wss) {
|
|
358
380
|
return new Promise((resolve) => {
|
|
359
381
|
this.wss.close(() => {
|
|
@@ -5,7 +5,10 @@ import { RelayServer } from '../extension/relay-server.js';
|
|
|
5
5
|
import { logRelay, logExtension, logInfo, logError } from './mcp-logger.js';
|
|
6
6
|
// Stable extension ID (from manifest.json key)
|
|
7
7
|
const EXTENSION_ID = 'ibjplbopgmcacpmfpnaeoloepdhenlbm';
|
|
8
|
-
|
|
8
|
+
// Wake connect page disabled by default — it opens a chrome-extension:// URL
|
|
9
|
+
// that gets ERR_BLOCKED_BY_CLIENT and annoys users. Discovery polling is the
|
|
10
|
+
// primary mechanism; wake is only useful in rare edge cases.
|
|
11
|
+
const ENABLE_WAKE_CONNECT_PAGE = process.env.CAI_ENABLE_WAKE_CONNECT_PAGE === '1';
|
|
9
12
|
/**
|
|
10
13
|
* Get Chrome executable path for current platform
|
|
11
14
|
*/
|
|
@@ -2032,16 +2032,20 @@ async function askChatGPTFastInternal(question, debug) {
|
|
|
2032
2032
|
summary: interceptor.getSummary(),
|
|
2033
2033
|
});
|
|
2034
2034
|
// Hybrid: prefer network text (primary), DOM as fallback
|
|
2035
|
+
// Use network if it captured anything and is at least 50% of DOM length
|
|
2036
|
+
// (avoids using truncated network text when DOM has the full answer)
|
|
2035
2037
|
let hybridAnswer = finalAnswer;
|
|
2036
2038
|
let answerSource = 'dom';
|
|
2037
|
-
|
|
2039
|
+
const netLen = networkResult.text.length;
|
|
2040
|
+
const domLen = finalAnswer.length;
|
|
2041
|
+
if (netLen > 0 && (domLen === 0 || netLen >= domLen * 0.5)) {
|
|
2038
2042
|
hybridAnswer = networkResult.text;
|
|
2039
2043
|
answerSource = 'network';
|
|
2040
2044
|
}
|
|
2041
2045
|
logInfo('chatgpt', 'Answer source selected', {
|
|
2042
2046
|
source: answerSource,
|
|
2043
|
-
networkLen:
|
|
2044
|
-
domLen
|
|
2047
|
+
networkLen: netLen,
|
|
2048
|
+
domLen,
|
|
2045
2049
|
});
|
|
2046
2050
|
return { answer: hybridAnswer, timings: fullTimings, debug: debugInfo };
|
|
2047
2051
|
}
|
|
@@ -3030,17 +3034,20 @@ async function askGeminiFastInternal(question, debug) {
|
|
|
3030
3034
|
});
|
|
3031
3035
|
// Hybrid: prefer network text (primary), DOM as fallback
|
|
3032
3036
|
// Normalize network text with same Gemini-specific cleanup as DOM text
|
|
3037
|
+
// Use network if it captured anything and is at least 50% of DOM length
|
|
3033
3038
|
const networkNormalized = normalizeGeminiResponse(networkResult.text, question);
|
|
3034
3039
|
let hybridAnswer = normalized;
|
|
3035
3040
|
let answerSource = 'dom';
|
|
3036
|
-
|
|
3041
|
+
const netLen = networkNormalized.length;
|
|
3042
|
+
const domLen = normalized.length;
|
|
3043
|
+
if (netLen > 0 && (domLen === 0 || netLen >= domLen * 0.5)) {
|
|
3037
3044
|
hybridAnswer = networkNormalized;
|
|
3038
3045
|
answerSource = 'network';
|
|
3039
3046
|
}
|
|
3040
3047
|
logInfo('gemini', 'Answer source selected', {
|
|
3041
3048
|
source: answerSource,
|
|
3042
|
-
networkLen:
|
|
3043
|
-
domLen
|
|
3049
|
+
networkLen: netLen,
|
|
3050
|
+
domLen,
|
|
3044
3051
|
});
|
|
3045
3052
|
return { answer: hybridAnswer, timings: fullTimings, debug: debugInfo };
|
|
3046
3053
|
}
|
|
@@ -146,8 +146,15 @@ export class NetworkInterceptor {
|
|
|
146
146
|
const isSSE = contentType.includes('text/event-stream');
|
|
147
147
|
if (isResponseUrl(url) || isSSE) {
|
|
148
148
|
this.pendingBodies.add(requestId);
|
|
149
|
-
this.fetchResponseBody(requestId, url).catch(() => {
|
|
150
|
-
|
|
149
|
+
this.fetchResponseBody(requestId, url).catch((err) => {
|
|
150
|
+
console.error(`[NetworkInterceptor] fetchResponseBody error for ${url.slice(0, 80)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (!url) {
|
|
154
|
+
// Speculative capture: requestWillBeSent was missed (tab reuse scenario)
|
|
155
|
+
// Try fetching body and check if it looks like ChatGPT SSE or Gemini response
|
|
156
|
+
this.speculativeFetchBody(requestId).catch(() => {
|
|
157
|
+
// Best-effort; silent failure expected
|
|
151
158
|
});
|
|
152
159
|
}
|
|
153
160
|
});
|
|
@@ -163,40 +170,87 @@ export class NetworkInterceptor {
|
|
|
163
170
|
this.client.on(event, wrappedHandler);
|
|
164
171
|
}
|
|
165
172
|
async fetchResponseBody(requestId, url) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
const maxRetries = 2;
|
|
174
|
+
const retryDelayMs = 500;
|
|
175
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await this.client.send('Network.getResponseBody', { requestId });
|
|
178
|
+
if (result?.body) {
|
|
179
|
+
let data;
|
|
180
|
+
if (result.base64Encoded) {
|
|
181
|
+
try {
|
|
182
|
+
data = Buffer.from(result.body, 'base64').toString('utf-8');
|
|
183
|
+
}
|
|
184
|
+
catch (decodeErr) {
|
|
185
|
+
console.error(`[NetworkInterceptor] Base64 decode failed for ${url.slice(0, 80)}: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}`);
|
|
186
|
+
this.pendingBodies.delete(requestId);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
173
189
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return;
|
|
190
|
+
else {
|
|
191
|
+
data = result.body;
|
|
177
192
|
}
|
|
193
|
+
this.frames.push({
|
|
194
|
+
timestamp: Date.now() / 1000,
|
|
195
|
+
type: 'fetch-body',
|
|
196
|
+
requestId,
|
|
197
|
+
url,
|
|
198
|
+
data,
|
|
199
|
+
});
|
|
200
|
+
console.error(`[NetworkInterceptor] Body captured: ${url.slice(0, 80)} (${data.length} bytes)`);
|
|
201
|
+
}
|
|
202
|
+
this.pendingBodies.delete(requestId);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
207
|
+
const isRetryable = msg.includes('No resource') || msg.includes('No data found');
|
|
208
|
+
if (isRetryable && attempt < maxRetries) {
|
|
209
|
+
console.error(`[NetworkInterceptor] getResponseBody retry ${attempt + 1}/${maxRetries} for ${url.slice(0, 80)}`);
|
|
210
|
+
await new Promise(r => setTimeout(r, retryDelayMs));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!isRetryable) {
|
|
214
|
+
console.error(`[NetworkInterceptor] getResponseBody failed for ${url.slice(0, 80)}: ${msg}`);
|
|
178
215
|
}
|
|
179
216
|
else {
|
|
180
|
-
|
|
217
|
+
console.error(`[NetworkInterceptor] getResponseBody failed after ${maxRetries} retries for ${url.slice(0, 80)}: ${msg}`);
|
|
181
218
|
}
|
|
219
|
+
this.pendingBodies.delete(requestId);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
this.pendingBodies.delete(requestId);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Speculative body fetch for requests where requestWillBeSent was missed
|
|
227
|
+
* (e.g., tab reuse). Checks if the body looks like a ChatGPT or Gemini response.
|
|
228
|
+
*/
|
|
229
|
+
async speculativeFetchBody(requestId) {
|
|
230
|
+
try {
|
|
231
|
+
const result = await this.client.send('Network.getResponseBody', { requestId });
|
|
232
|
+
if (!result?.body)
|
|
233
|
+
return;
|
|
234
|
+
const data = result.base64Encoded
|
|
235
|
+
? Buffer.from(result.body, 'base64').toString('utf-8')
|
|
236
|
+
: result.body;
|
|
237
|
+
// Check if body looks like ChatGPT SSE (contains "data: " lines and [DONE])
|
|
238
|
+
const isChatGPTSSE = data.includes('data: ') && data.includes('[DONE]');
|
|
239
|
+
// Check if body looks like Gemini response (starts with )]}')
|
|
240
|
+
const isGemini = data.startsWith(")]}'");
|
|
241
|
+
if (isChatGPTSSE || isGemini) {
|
|
182
242
|
this.frames.push({
|
|
183
243
|
timestamp: Date.now() / 1000,
|
|
184
244
|
type: 'fetch-body',
|
|
185
245
|
requestId,
|
|
186
|
-
url,
|
|
246
|
+
url: '<speculative>',
|
|
187
247
|
data,
|
|
188
248
|
});
|
|
249
|
+
console.error(`[NetworkInterceptor] Speculative capture hit: ${isChatGPTSSE ? 'ChatGPT SSE' : 'Gemini'} (${data.length} bytes)`);
|
|
189
250
|
}
|
|
190
251
|
}
|
|
191
|
-
catch
|
|
192
|
-
//
|
|
193
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
194
|
-
if (!msg.includes('No resource') && !msg.includes('No data found')) {
|
|
195
|
-
console.error(`[NetworkInterceptor] getResponseBody failed for ${url.slice(0, 80)}: ${msg}`);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
finally {
|
|
199
|
-
this.pendingBodies.delete(requestId);
|
|
252
|
+
catch {
|
|
253
|
+
// Silent: speculative capture is best-effort
|
|
200
254
|
}
|
|
201
255
|
}
|
|
202
256
|
stopCapture() {
|
|
@@ -212,14 +266,30 @@ export class NetworkInterceptor {
|
|
|
212
266
|
}
|
|
213
267
|
/**
|
|
214
268
|
* Wait for all pending response body fetches, then stop capture.
|
|
269
|
+
* IMPORTANT: capturing remains true during the wait so that late-arriving
|
|
270
|
+
* loadingFinished events are still processed (this was the root cause of
|
|
271
|
+
* textLength=0 — setting capturing=false first dropped those events).
|
|
215
272
|
*/
|
|
216
|
-
async stopCaptureAndWait(timeoutMs =
|
|
217
|
-
|
|
218
|
-
// Wait for pending body fetches
|
|
273
|
+
async stopCaptureAndWait(timeoutMs = 15000) {
|
|
274
|
+
// Phase 1: Wait for pending body fetches (capturing stays true)
|
|
219
275
|
const deadline = Date.now() + timeoutMs;
|
|
220
276
|
while (this.pendingBodies.size > 0 && Date.now() < deadline) {
|
|
221
277
|
await new Promise(r => setTimeout(r, 100));
|
|
222
278
|
}
|
|
279
|
+
// Phase 2: Grace period — if no frames captured yet, wait for late loadingFinished
|
|
280
|
+
if (this.frames.length === 0) {
|
|
281
|
+
const graceDeadline = Math.min(Date.now() + 3000, deadline);
|
|
282
|
+
console.error('[NetworkInterceptor] No frames yet, waiting grace period for late events...');
|
|
283
|
+
while (this.frames.length === 0 && Date.now() < graceDeadline) {
|
|
284
|
+
await new Promise(r => setTimeout(r, 100));
|
|
285
|
+
// Also wait for any new pending bodies that appeared during grace
|
|
286
|
+
while (this.pendingBodies.size > 0 && Date.now() < graceDeadline) {
|
|
287
|
+
await new Promise(r => setTimeout(r, 100));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Phase 3: Now stop capturing
|
|
292
|
+
this.capturing = false;
|
|
223
293
|
// Warn if pending bodies remain after timeout
|
|
224
294
|
if (this.pendingBodies.size > 0) {
|
|
225
295
|
console.warn(`[NetworkInterceptor] Timeout: ${this.pendingBodies.size} pending bodies abandoned (requestIds: ${[...this.pendingBodies].slice(0, 3).join(', ')}${this.pendingBodies.size > 3 ? '...' : ''})`);
|
package/package.json
CHANGED