@flrande/bak-extension 0.6.9 → 0.6.10
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/.bak-e2e-build-stamp +1 -1
- package/dist/background.global.js +127 -205
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/background.ts +1949 -1991
- package/src/session-binding.ts +387 -437
package/src/background.ts
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ConsoleEntry,
|
|
3
|
-
DebugDumpSection,
|
|
4
|
-
Locator,
|
|
5
|
-
NetworkEntry,
|
|
6
|
-
PageExecutionScope,
|
|
7
|
-
PageFetchResponse,
|
|
8
|
-
PageFrameResult,
|
|
9
|
-
PageFreshnessResult,
|
|
10
|
-
TableHandle,
|
|
11
|
-
TableSchema
|
|
12
|
-
} from '@flrande/bak-protocol';
|
|
13
|
-
import {
|
|
14
|
-
clearNetworkEntries,
|
|
15
|
-
dropNetworkCapture,
|
|
16
|
-
ensureNetworkDebugger,
|
|
17
|
-
exportHar,
|
|
18
|
-
getNetworkEntry,
|
|
19
|
-
latestNetworkTimestamp,
|
|
20
|
-
listNetworkEntries,
|
|
21
|
-
recentNetworkSampleIds,
|
|
22
|
-
searchNetworkEntries,
|
|
23
|
-
waitForNetworkEntry
|
|
24
|
-
} from './network-debugger.js';
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleEntry,
|
|
3
|
+
DebugDumpSection,
|
|
4
|
+
Locator,
|
|
5
|
+
NetworkEntry,
|
|
6
|
+
PageExecutionScope,
|
|
7
|
+
PageFetchResponse,
|
|
8
|
+
PageFrameResult,
|
|
9
|
+
PageFreshnessResult,
|
|
10
|
+
TableHandle,
|
|
11
|
+
TableSchema
|
|
12
|
+
} from '@flrande/bak-protocol';
|
|
13
|
+
import {
|
|
14
|
+
clearNetworkEntries,
|
|
15
|
+
dropNetworkCapture,
|
|
16
|
+
ensureNetworkDebugger,
|
|
17
|
+
exportHar,
|
|
18
|
+
getNetworkEntry,
|
|
19
|
+
latestNetworkTimestamp,
|
|
20
|
+
listNetworkEntries,
|
|
21
|
+
recentNetworkSampleIds,
|
|
22
|
+
searchNetworkEntries,
|
|
23
|
+
waitForNetworkEntry
|
|
24
|
+
} from './network-debugger.js';
|
|
25
25
|
import { isSupportedAutomationUrl } from './url-policy.js';
|
|
26
26
|
import { computeReconnectDelayMs } from './reconnect.js';
|
|
27
27
|
import { resolveSessionBindingStateMap, STORAGE_KEY_SESSION_BINDINGS } from './session-binding-storage.js';
|
|
28
28
|
import { containsRedactionMarker } from './privacy.js';
|
|
29
29
|
import {
|
|
30
30
|
type SessionBindingBrowser,
|
|
31
|
-
type SessionBindingColor,
|
|
32
|
-
type SessionBindingRecord,
|
|
33
|
-
type SessionBindingTab,
|
|
34
|
-
type SessionBindingWindow,
|
|
35
|
-
SessionBindingManager
|
|
36
|
-
} from './session-binding.js';
|
|
37
|
-
import { EXTENSION_VERSION } from './version.js';
|
|
31
|
+
type SessionBindingColor,
|
|
32
|
+
type SessionBindingRecord,
|
|
33
|
+
type SessionBindingTab,
|
|
34
|
+
type SessionBindingWindow,
|
|
35
|
+
SessionBindingManager
|
|
36
|
+
} from './session-binding.js';
|
|
37
|
+
import { EXTENSION_VERSION } from './version.js';
|
|
38
38
|
|
|
39
39
|
interface CliRequest {
|
|
40
40
|
id: string;
|
|
@@ -59,103 +59,103 @@ interface ExtensionConfig {
|
|
|
59
59
|
debugRichText: boolean;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
interface RuntimeErrorDetails {
|
|
63
|
-
message: string;
|
|
64
|
-
context: 'config' | 'socket' | 'request' | 'parse';
|
|
65
|
-
at: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface PopupSessionBindingSummary {
|
|
69
|
-
id: string;
|
|
70
|
-
label: string;
|
|
71
|
-
tabCount: number;
|
|
72
|
-
activeTabId: number | null;
|
|
73
|
-
windowId: number | null;
|
|
74
|
-
groupId: number | null;
|
|
75
|
-
detached: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface PopupState {
|
|
79
|
-
ok: true;
|
|
80
|
-
connected: boolean;
|
|
81
|
-
connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
|
|
82
|
-
hasToken: boolean;
|
|
83
|
-
port: number;
|
|
84
|
-
wsUrl: string;
|
|
85
|
-
debugRichText: boolean;
|
|
86
|
-
lastError: string | null;
|
|
87
|
-
lastErrorAt: number | null;
|
|
88
|
-
lastErrorContext: RuntimeErrorDetails['context'] | null;
|
|
89
|
-
reconnectAttempt: number;
|
|
90
|
-
nextReconnectInMs: number | null;
|
|
91
|
-
manualDisconnect: boolean;
|
|
92
|
-
extensionVersion: string;
|
|
93
|
-
lastBindingUpdateAt: number | null;
|
|
94
|
-
lastBindingUpdateReason: string | null;
|
|
95
|
-
sessionBindings: {
|
|
96
|
-
count: number;
|
|
97
|
-
attachedCount: number;
|
|
98
|
-
detachedCount: number;
|
|
99
|
-
tabCount: number;
|
|
100
|
-
items: PopupSessionBindingSummary[];
|
|
101
|
-
};
|
|
102
|
-
}
|
|
62
|
+
interface RuntimeErrorDetails {
|
|
63
|
+
message: string;
|
|
64
|
+
context: 'config' | 'socket' | 'request' | 'parse';
|
|
65
|
+
at: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PopupSessionBindingSummary {
|
|
69
|
+
id: string;
|
|
70
|
+
label: string;
|
|
71
|
+
tabCount: number;
|
|
72
|
+
activeTabId: number | null;
|
|
73
|
+
windowId: number | null;
|
|
74
|
+
groupId: number | null;
|
|
75
|
+
detached: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PopupState {
|
|
79
|
+
ok: true;
|
|
80
|
+
connected: boolean;
|
|
81
|
+
connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
|
|
82
|
+
hasToken: boolean;
|
|
83
|
+
port: number;
|
|
84
|
+
wsUrl: string;
|
|
85
|
+
debugRichText: boolean;
|
|
86
|
+
lastError: string | null;
|
|
87
|
+
lastErrorAt: number | null;
|
|
88
|
+
lastErrorContext: RuntimeErrorDetails['context'] | null;
|
|
89
|
+
reconnectAttempt: number;
|
|
90
|
+
nextReconnectInMs: number | null;
|
|
91
|
+
manualDisconnect: boolean;
|
|
92
|
+
extensionVersion: string;
|
|
93
|
+
lastBindingUpdateAt: number | null;
|
|
94
|
+
lastBindingUpdateReason: string | null;
|
|
95
|
+
sessionBindings: {
|
|
96
|
+
count: number;
|
|
97
|
+
attachedCount: number;
|
|
98
|
+
detachedCount: number;
|
|
99
|
+
tabCount: number;
|
|
100
|
+
items: PopupSessionBindingSummary[];
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
103
|
|
|
104
104
|
const DEFAULT_PORT = 17373;
|
|
105
105
|
const STORAGE_KEY_TOKEN = 'pairToken';
|
|
106
|
-
const STORAGE_KEY_PORT = 'cliPort';
|
|
107
|
-
const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
|
|
108
|
-
const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
|
|
109
|
-
const textEncoder = new TextEncoder();
|
|
110
|
-
const textDecoder = new TextDecoder();
|
|
111
|
-
const DATA_TIMESTAMP_CONTEXT_PATTERN =
|
|
112
|
-
/\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|freshness|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\b/i;
|
|
113
|
-
const CONTRACT_TIMESTAMP_CONTEXT_PATTERN =
|
|
114
|
-
/\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\b/i;
|
|
115
|
-
const EVENT_TIMESTAMP_CONTEXT_PATTERN = /\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\b/i;
|
|
116
|
-
|
|
117
|
-
interface TimestampEvidenceCandidate {
|
|
118
|
-
value: string;
|
|
119
|
-
source: 'visible' | 'inline' | 'page-data' | 'network';
|
|
120
|
-
context?: string;
|
|
121
|
-
path?: string;
|
|
122
|
-
category?: 'data' | 'contract' | 'event' | 'unknown';
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
interface PageDataCandidateProbe {
|
|
126
|
-
name: string;
|
|
127
|
-
resolver: 'globalThis' | 'lexical';
|
|
128
|
-
sample: unknown;
|
|
129
|
-
timestamps: Array<{
|
|
130
|
-
path: string;
|
|
131
|
-
value: string;
|
|
132
|
-
category: 'data' | 'contract' | 'event' | 'unknown';
|
|
133
|
-
}>;
|
|
134
|
-
}
|
|
135
|
-
const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
|
|
136
|
-
'accept-encoding',
|
|
137
|
-
'authorization',
|
|
138
|
-
'connection',
|
|
139
|
-
'content-length',
|
|
140
|
-
'cookie',
|
|
141
|
-
'host',
|
|
142
|
-
'origin',
|
|
143
|
-
'proxy-authorization',
|
|
144
|
-
'referer',
|
|
145
|
-
'set-cookie'
|
|
146
|
-
]);
|
|
147
|
-
|
|
148
|
-
let ws: WebSocket | null = null;
|
|
149
|
-
let reconnectTimer: number | null = null;
|
|
150
|
-
let nextReconnectInMs: number | null = null;
|
|
151
|
-
let nextReconnectAt: number | null = null;
|
|
152
|
-
let reconnectAttempt = 0;
|
|
153
|
-
let lastError: RuntimeErrorDetails | null = null;
|
|
154
|
-
let manualDisconnect = false;
|
|
155
|
-
let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
|
|
156
|
-
let preserveHumanFocusDepth = 0;
|
|
157
|
-
let lastBindingUpdateAt: number | null = null;
|
|
158
|
-
let lastBindingUpdateReason: string | null = null;
|
|
106
|
+
const STORAGE_KEY_PORT = 'cliPort';
|
|
107
|
+
const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
|
|
108
|
+
const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
|
|
109
|
+
const textEncoder = new TextEncoder();
|
|
110
|
+
const textDecoder = new TextDecoder();
|
|
111
|
+
const DATA_TIMESTAMP_CONTEXT_PATTERN =
|
|
112
|
+
/\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|freshness|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\b/i;
|
|
113
|
+
const CONTRACT_TIMESTAMP_CONTEXT_PATTERN =
|
|
114
|
+
/\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\b/i;
|
|
115
|
+
const EVENT_TIMESTAMP_CONTEXT_PATTERN = /\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\b/i;
|
|
116
|
+
|
|
117
|
+
interface TimestampEvidenceCandidate {
|
|
118
|
+
value: string;
|
|
119
|
+
source: 'visible' | 'inline' | 'page-data' | 'network';
|
|
120
|
+
context?: string;
|
|
121
|
+
path?: string;
|
|
122
|
+
category?: 'data' | 'contract' | 'event' | 'unknown';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface PageDataCandidateProbe {
|
|
126
|
+
name: string;
|
|
127
|
+
resolver: 'globalThis' | 'lexical';
|
|
128
|
+
sample: unknown;
|
|
129
|
+
timestamps: Array<{
|
|
130
|
+
path: string;
|
|
131
|
+
value: string;
|
|
132
|
+
category: 'data' | 'contract' | 'event' | 'unknown';
|
|
133
|
+
}>;
|
|
134
|
+
}
|
|
135
|
+
const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
|
|
136
|
+
'accept-encoding',
|
|
137
|
+
'authorization',
|
|
138
|
+
'connection',
|
|
139
|
+
'content-length',
|
|
140
|
+
'cookie',
|
|
141
|
+
'host',
|
|
142
|
+
'origin',
|
|
143
|
+
'proxy-authorization',
|
|
144
|
+
'referer',
|
|
145
|
+
'set-cookie'
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
let ws: WebSocket | null = null;
|
|
149
|
+
let reconnectTimer: number | null = null;
|
|
150
|
+
let nextReconnectInMs: number | null = null;
|
|
151
|
+
let nextReconnectAt: number | null = null;
|
|
152
|
+
let reconnectAttempt = 0;
|
|
153
|
+
let lastError: RuntimeErrorDetails | null = null;
|
|
154
|
+
let manualDisconnect = false;
|
|
155
|
+
let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
|
|
156
|
+
let preserveHumanFocusDepth = 0;
|
|
157
|
+
let lastBindingUpdateAt: number | null = null;
|
|
158
|
+
let lastBindingUpdateReason: string | null = null;
|
|
159
159
|
|
|
160
160
|
async function getConfig(): Promise<ExtensionConfig> {
|
|
161
161
|
const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
|
|
@@ -190,33 +190,33 @@ function setRuntimeError(message: string, context: RuntimeErrorDetails['context'
|
|
|
190
190
|
};
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
function clearReconnectTimer(): void {
|
|
194
|
-
if (reconnectTimer !== null) {
|
|
195
|
-
clearTimeout(reconnectTimer);
|
|
196
|
-
reconnectTimer = null;
|
|
197
|
-
}
|
|
198
|
-
nextReconnectInMs = null;
|
|
199
|
-
nextReconnectAt = null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function sendResponse(payload: CliResponse): void {
|
|
203
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
204
|
-
ws.send(JSON.stringify(payload));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function sendEvent(event: string, data: Record<string, unknown>): void {
|
|
209
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
210
|
-
ws.send(
|
|
211
|
-
JSON.stringify({
|
|
212
|
-
type: 'event',
|
|
213
|
-
event,
|
|
214
|
-
data,
|
|
215
|
-
ts: Date.now()
|
|
216
|
-
})
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
193
|
+
function clearReconnectTimer(): void {
|
|
194
|
+
if (reconnectTimer !== null) {
|
|
195
|
+
clearTimeout(reconnectTimer);
|
|
196
|
+
reconnectTimer = null;
|
|
197
|
+
}
|
|
198
|
+
nextReconnectInMs = null;
|
|
199
|
+
nextReconnectAt = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sendResponse(payload: CliResponse): void {
|
|
203
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
204
|
+
ws.send(JSON.stringify(payload));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sendEvent(event: string, data: Record<string, unknown>): void {
|
|
209
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
210
|
+
ws.send(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
type: 'event',
|
|
213
|
+
event,
|
|
214
|
+
data,
|
|
215
|
+
ts: Date.now()
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
220
|
|
|
221
221
|
function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
|
|
222
222
|
return { code, message, data };
|
|
@@ -233,12 +233,12 @@ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
|
|
|
233
233
|
if (lower.includes('no tab with id') || lower.includes('no window with id')) {
|
|
234
234
|
return toError('E_NOT_FOUND', message);
|
|
235
235
|
}
|
|
236
|
-
if (lower.includes('binding') && lower.includes('does not exist')) {
|
|
237
|
-
return toError('E_NOT_FOUND', message);
|
|
238
|
-
}
|
|
239
|
-
if (lower.includes('does not belong to binding') || lower.includes('is missing from binding')) {
|
|
240
|
-
return toError('E_NOT_FOUND', message);
|
|
241
|
-
}
|
|
236
|
+
if (lower.includes('binding') && lower.includes('does not exist')) {
|
|
237
|
+
return toError('E_NOT_FOUND', message);
|
|
238
|
+
}
|
|
239
|
+
if (lower.includes('does not belong to binding') || lower.includes('is missing from binding')) {
|
|
240
|
+
return toError('E_NOT_FOUND', message);
|
|
241
|
+
}
|
|
242
242
|
if (lower.includes('invalid url') || lower.includes('url is invalid')) {
|
|
243
243
|
return toError('E_INVALID_PARAMS', message);
|
|
244
244
|
}
|
|
@@ -263,155 +263,155 @@ function toTabInfo(tab: chrome.tabs.Tab): SessionBindingTab {
|
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
async function readSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
|
|
267
|
-
const stored = await chrome.storage.local.get([STORAGE_KEY_SESSION_BINDINGS]);
|
|
268
|
-
return resolveSessionBindingStateMap(stored);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function flushSessionBindingStateMap(stateMap: Record<string, SessionBindingRecord>): Promise<void> {
|
|
272
|
-
if (Object.keys(stateMap).length === 0) {
|
|
273
|
-
await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS]);
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function runSessionBindingStateMutation<T>(operation: () => Promise<T>): Promise<T> {
|
|
280
|
-
const run = sessionBindingStateMutationQueue.then(operation, operation);
|
|
281
|
-
sessionBindingStateMutationQueue = run.then(
|
|
282
|
-
() => undefined,
|
|
283
|
-
() => undefined
|
|
284
|
-
);
|
|
285
|
-
return run;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function mutateSessionBindingStateMap<T>(mutator: (stateMap: Record<string, SessionBindingRecord>) => Promise<T> | T): Promise<T> {
|
|
289
|
-
return await runSessionBindingStateMutation(async () => {
|
|
290
|
-
const stateMap = await readSessionBindingStateMap();
|
|
291
|
-
const result = await mutator(stateMap);
|
|
292
|
-
await flushSessionBindingStateMap(stateMap);
|
|
293
|
-
return result;
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
async function loadSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
|
|
298
|
-
await sessionBindingStateMutationQueue;
|
|
299
|
-
return await readSessionBindingStateMap();
|
|
300
|
-
}
|
|
266
|
+
async function readSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
|
|
267
|
+
const stored = await chrome.storage.local.get([STORAGE_KEY_SESSION_BINDINGS]);
|
|
268
|
+
return resolveSessionBindingStateMap(stored);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function flushSessionBindingStateMap(stateMap: Record<string, SessionBindingRecord>): Promise<void> {
|
|
272
|
+
if (Object.keys(stateMap).length === 0) {
|
|
273
|
+
await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS]);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function runSessionBindingStateMutation<T>(operation: () => Promise<T>): Promise<T> {
|
|
280
|
+
const run = sessionBindingStateMutationQueue.then(operation, operation);
|
|
281
|
+
sessionBindingStateMutationQueue = run.then(
|
|
282
|
+
() => undefined,
|
|
283
|
+
() => undefined
|
|
284
|
+
);
|
|
285
|
+
return run;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function mutateSessionBindingStateMap<T>(mutator: (stateMap: Record<string, SessionBindingRecord>) => Promise<T> | T): Promise<T> {
|
|
289
|
+
return await runSessionBindingStateMutation(async () => {
|
|
290
|
+
const stateMap = await readSessionBindingStateMap();
|
|
291
|
+
const result = await mutator(stateMap);
|
|
292
|
+
await flushSessionBindingStateMap(stateMap);
|
|
293
|
+
return result;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function loadSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
|
|
298
|
+
await sessionBindingStateMutationQueue;
|
|
299
|
+
return await readSessionBindingStateMap();
|
|
300
|
+
}
|
|
301
301
|
|
|
302
302
|
async function loadSessionBindingState(bindingId: string): Promise<SessionBindingRecord | null> {
|
|
303
303
|
const stateMap = await loadSessionBindingStateMap();
|
|
304
304
|
return stateMap[bindingId] ?? null;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
|
|
308
|
-
return Object.values(await loadSessionBindingStateMap());
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function summarizeSessionBindings(states: SessionBindingRecord[]): PopupState['sessionBindings'] {
|
|
312
|
-
const items = states.map((state) => {
|
|
313
|
-
const detached = state.windowId === null || state.tabIds.length === 0;
|
|
314
|
-
return {
|
|
315
|
-
id: state.id,
|
|
316
|
-
label: state.label,
|
|
317
|
-
tabCount: state.tabIds.length,
|
|
318
|
-
activeTabId: state.activeTabId,
|
|
319
|
-
windowId: state.windowId,
|
|
320
|
-
groupId: state.groupId,
|
|
321
|
-
detached
|
|
322
|
-
} satisfies PopupSessionBindingSummary;
|
|
323
|
-
});
|
|
324
|
-
return {
|
|
325
|
-
count: items.length,
|
|
326
|
-
attachedCount: items.filter((item) => !item.detached).length,
|
|
327
|
-
detachedCount: items.filter((item) => item.detached).length,
|
|
328
|
-
tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
|
|
329
|
-
items
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async function buildPopupState(): Promise<PopupState> {
|
|
334
|
-
const config = await getConfig();
|
|
335
|
-
const sessionBindings = summarizeSessionBindings(await listSessionBindingStates());
|
|
336
|
-
const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
|
|
337
|
-
let connectionState: PopupState['connectionState'];
|
|
338
|
-
if (!config.token) {
|
|
339
|
-
connectionState = 'missing-token';
|
|
340
|
-
} else if (ws?.readyState === WebSocket.OPEN) {
|
|
341
|
-
connectionState = 'connected';
|
|
342
|
-
} else if (ws?.readyState === WebSocket.CONNECTING) {
|
|
343
|
-
connectionState = 'connecting';
|
|
344
|
-
} else if (manualDisconnect) {
|
|
345
|
-
connectionState = 'manual';
|
|
346
|
-
} else if (nextReconnectInMs !== null) {
|
|
347
|
-
connectionState = 'reconnecting';
|
|
348
|
-
} else {
|
|
349
|
-
connectionState = 'disconnected';
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return {
|
|
353
|
-
ok: true,
|
|
354
|
-
connected: ws?.readyState === WebSocket.OPEN,
|
|
355
|
-
connectionState,
|
|
356
|
-
hasToken: Boolean(config.token),
|
|
357
|
-
port: config.port,
|
|
358
|
-
wsUrl: `ws://127.0.0.1:${config.port}/extension`,
|
|
359
|
-
debugRichText: config.debugRichText,
|
|
360
|
-
lastError: lastError?.message ?? null,
|
|
361
|
-
lastErrorAt: lastError?.at ?? null,
|
|
362
|
-
lastErrorContext: lastError?.context ?? null,
|
|
363
|
-
reconnectAttempt,
|
|
364
|
-
nextReconnectInMs: reconnectRemainingMs,
|
|
365
|
-
manualDisconnect,
|
|
366
|
-
extensionVersion: EXTENSION_VERSION,
|
|
367
|
-
lastBindingUpdateAt,
|
|
368
|
-
lastBindingUpdateReason,
|
|
369
|
-
sessionBindings
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
async function saveSessionBindingState(state: SessionBindingRecord): Promise<void> {
|
|
374
|
-
await mutateSessionBindingStateMap((stateMap) => {
|
|
375
|
-
stateMap[state.id] = state;
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async function deleteSessionBindingState(bindingId: string): Promise<void> {
|
|
380
|
-
await mutateSessionBindingStateMap((stateMap) => {
|
|
381
|
-
delete stateMap[bindingId];
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function toSessionBindingEventBrowser(state: SessionBindingRecord | null): Record<string, unknown> | null {
|
|
386
|
-
if (!state) {
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
389
|
-
return {
|
|
390
|
-
windowId: state.windowId,
|
|
391
|
-
groupId: state.groupId,
|
|
392
|
-
tabIds: [...state.tabIds],
|
|
393
|
-
activeTabId: state.activeTabId,
|
|
394
|
-
primaryTabId: state.primaryTabId
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function emitSessionBindingUpdated(
|
|
399
|
-
bindingId: string,
|
|
400
|
-
reason: string,
|
|
401
|
-
state: SessionBindingRecord | null,
|
|
402
|
-
extras: Record<string, unknown> = {}
|
|
403
|
-
): void {
|
|
404
|
-
lastBindingUpdateAt = Date.now();
|
|
405
|
-
lastBindingUpdateReason = reason;
|
|
406
|
-
sendEvent('sessionBinding.updated', {
|
|
407
|
-
bindingId,
|
|
408
|
-
reason,
|
|
409
|
-
browser: toSessionBindingEventBrowser(state),
|
|
410
|
-
...extras
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const sessionBindingBrowser: SessionBindingBrowser = {
|
|
307
|
+
async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
|
|
308
|
+
return Object.values(await loadSessionBindingStateMap());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function summarizeSessionBindings(states: SessionBindingRecord[]): PopupState['sessionBindings'] {
|
|
312
|
+
const items = states.map((state) => {
|
|
313
|
+
const detached = state.windowId === null || state.tabIds.length === 0;
|
|
314
|
+
return {
|
|
315
|
+
id: state.id,
|
|
316
|
+
label: state.label,
|
|
317
|
+
tabCount: state.tabIds.length,
|
|
318
|
+
activeTabId: state.activeTabId,
|
|
319
|
+
windowId: state.windowId,
|
|
320
|
+
groupId: state.groupId,
|
|
321
|
+
detached
|
|
322
|
+
} satisfies PopupSessionBindingSummary;
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
count: items.length,
|
|
326
|
+
attachedCount: items.filter((item) => !item.detached).length,
|
|
327
|
+
detachedCount: items.filter((item) => item.detached).length,
|
|
328
|
+
tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
|
|
329
|
+
items
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function buildPopupState(): Promise<PopupState> {
|
|
334
|
+
const config = await getConfig();
|
|
335
|
+
const sessionBindings = summarizeSessionBindings(await listSessionBindingStates());
|
|
336
|
+
const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
|
|
337
|
+
let connectionState: PopupState['connectionState'];
|
|
338
|
+
if (!config.token) {
|
|
339
|
+
connectionState = 'missing-token';
|
|
340
|
+
} else if (ws?.readyState === WebSocket.OPEN) {
|
|
341
|
+
connectionState = 'connected';
|
|
342
|
+
} else if (ws?.readyState === WebSocket.CONNECTING) {
|
|
343
|
+
connectionState = 'connecting';
|
|
344
|
+
} else if (manualDisconnect) {
|
|
345
|
+
connectionState = 'manual';
|
|
346
|
+
} else if (nextReconnectInMs !== null) {
|
|
347
|
+
connectionState = 'reconnecting';
|
|
348
|
+
} else {
|
|
349
|
+
connectionState = 'disconnected';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
355
|
+
connectionState,
|
|
356
|
+
hasToken: Boolean(config.token),
|
|
357
|
+
port: config.port,
|
|
358
|
+
wsUrl: `ws://127.0.0.1:${config.port}/extension`,
|
|
359
|
+
debugRichText: config.debugRichText,
|
|
360
|
+
lastError: lastError?.message ?? null,
|
|
361
|
+
lastErrorAt: lastError?.at ?? null,
|
|
362
|
+
lastErrorContext: lastError?.context ?? null,
|
|
363
|
+
reconnectAttempt,
|
|
364
|
+
nextReconnectInMs: reconnectRemainingMs,
|
|
365
|
+
manualDisconnect,
|
|
366
|
+
extensionVersion: EXTENSION_VERSION,
|
|
367
|
+
lastBindingUpdateAt,
|
|
368
|
+
lastBindingUpdateReason,
|
|
369
|
+
sessionBindings
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function saveSessionBindingState(state: SessionBindingRecord): Promise<void> {
|
|
374
|
+
await mutateSessionBindingStateMap((stateMap) => {
|
|
375
|
+
stateMap[state.id] = state;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function deleteSessionBindingState(bindingId: string): Promise<void> {
|
|
380
|
+
await mutateSessionBindingStateMap((stateMap) => {
|
|
381
|
+
delete stateMap[bindingId];
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function toSessionBindingEventBrowser(state: SessionBindingRecord | null): Record<string, unknown> | null {
|
|
386
|
+
if (!state) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
windowId: state.windowId,
|
|
391
|
+
groupId: state.groupId,
|
|
392
|
+
tabIds: [...state.tabIds],
|
|
393
|
+
activeTabId: state.activeTabId,
|
|
394
|
+
primaryTabId: state.primaryTabId
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function emitSessionBindingUpdated(
|
|
399
|
+
bindingId: string,
|
|
400
|
+
reason: string,
|
|
401
|
+
state: SessionBindingRecord | null,
|
|
402
|
+
extras: Record<string, unknown> = {}
|
|
403
|
+
): void {
|
|
404
|
+
lastBindingUpdateAt = Date.now();
|
|
405
|
+
lastBindingUpdateReason = reason;
|
|
406
|
+
sendEvent('sessionBinding.updated', {
|
|
407
|
+
bindingId,
|
|
408
|
+
reason,
|
|
409
|
+
browser: toSessionBindingEventBrowser(state),
|
|
410
|
+
...extras
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const sessionBindingBrowser: SessionBindingBrowser = {
|
|
415
415
|
async getTab(tabId) {
|
|
416
416
|
try {
|
|
417
417
|
return toTabInfo(await chrome.tabs.get(tabId));
|
|
@@ -469,68 +469,25 @@ const sessionBindingBrowser: SessionBindingBrowser = {
|
|
|
469
469
|
}
|
|
470
470
|
},
|
|
471
471
|
async createWindow(options) {
|
|
472
|
-
const
|
|
473
|
-
options.focused
|
|
474
|
-
|
|
475
|
-
: (await chrome.windows.getAll()).find((window) => window.focused === true && typeof window.id === 'number') ?? null;
|
|
476
|
-
const previouslyFocusedTabs =
|
|
477
|
-
previouslyFocusedWindow?.id !== undefined ? await chrome.tabs.query({ windowId: previouslyFocusedWindow.id }) : [];
|
|
478
|
-
const previouslyFocusedTabIds = new Set(
|
|
479
|
-
previouslyFocusedTabs.flatMap((tab) => (typeof tab.id === 'number' ? [tab.id] : []))
|
|
480
|
-
);
|
|
481
|
-
const previouslyFocusedTab =
|
|
482
|
-
previouslyFocusedTabs.find((tab) => tab.active === true && typeof tab.id === 'number') ?? null;
|
|
483
|
-
const desiredUrl = options.url ?? 'about:blank';
|
|
484
|
-
let created = await chrome.windows.create({
|
|
485
|
-
url: desiredUrl,
|
|
486
|
-
focused: true
|
|
472
|
+
const created = await chrome.windows.create({
|
|
473
|
+
focused: options.focused,
|
|
474
|
+
url: options.url ?? 'about:blank'
|
|
487
475
|
});
|
|
488
476
|
if (!created || typeof created.id !== 'number') {
|
|
489
477
|
throw new Error('Window missing id');
|
|
490
478
|
}
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
newlyCreatedTab ??
|
|
500
|
-
tabs.find((tab) => {
|
|
501
|
-
const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
|
|
502
|
-
return normalizeComparableTabUrl(tab.url ?? pendingUrl) === normalizedDesiredUrl;
|
|
503
|
-
}) ??
|
|
504
|
-
tabs.find((tab) => tab.active === true && typeof tab.id === 'number') ??
|
|
505
|
-
tabs.find((tab) => typeof tab.id === 'number') ??
|
|
506
|
-
null
|
|
507
|
-
);
|
|
508
|
-
};
|
|
509
|
-
let seedTab = await pickSeedTab(created.id);
|
|
510
|
-
const createdWindowTabs = await chrome.tabs.query({ windowId: created.id });
|
|
511
|
-
const createdWindowReusedFocusedWindow = previouslyFocusedWindow?.id === created.id;
|
|
512
|
-
const createdWindowLooksDirty = createdWindowTabs.length > 1;
|
|
513
|
-
if ((createdWindowReusedFocusedWindow || createdWindowLooksDirty) && typeof seedTab?.id === 'number') {
|
|
514
|
-
created = await chrome.windows.create({
|
|
515
|
-
tabId: seedTab.id,
|
|
516
|
-
focused: true
|
|
517
|
-
});
|
|
518
|
-
if (!created || typeof created.id !== 'number') {
|
|
519
|
-
throw new Error('Lifted window missing id');
|
|
520
|
-
}
|
|
521
|
-
seedTab = await pickSeedTab(created.id);
|
|
522
|
-
}
|
|
523
|
-
if (options.focused !== true && previouslyFocusedWindow?.id && previouslyFocusedWindow.id !== created.id) {
|
|
524
|
-
await chrome.windows.update(previouslyFocusedWindow.id, { focused: true });
|
|
525
|
-
if (typeof previouslyFocusedTab?.id === 'number') {
|
|
526
|
-
await chrome.tabs.update(previouslyFocusedTab.id, { active: true });
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
const finalWindow = await chrome.windows.get(created.id);
|
|
479
|
+
const initialTabId =
|
|
480
|
+
created.tabs?.find((tab) => typeof tab.id === 'number')?.id ??
|
|
481
|
+
(
|
|
482
|
+
await chrome.tabs.query({
|
|
483
|
+
windowId: created.id
|
|
484
|
+
})
|
|
485
|
+
).find((tab) => typeof tab.id === 'number')?.id ??
|
|
486
|
+
null;
|
|
530
487
|
return {
|
|
531
|
-
id:
|
|
532
|
-
focused: Boolean(
|
|
533
|
-
initialTabId
|
|
488
|
+
id: created.id,
|
|
489
|
+
focused: Boolean(created.focused),
|
|
490
|
+
initialTabId
|
|
534
491
|
};
|
|
535
492
|
},
|
|
536
493
|
async updateWindow(windowId, options) {
|
|
@@ -587,13 +544,13 @@ const sessionBindingBrowser: SessionBindingBrowser = {
|
|
|
587
544
|
}
|
|
588
545
|
};
|
|
589
546
|
|
|
590
|
-
const bindingManager = new SessionBindingManager(
|
|
591
|
-
{
|
|
592
|
-
load: loadSessionBindingState,
|
|
593
|
-
save: saveSessionBindingState,
|
|
594
|
-
delete: deleteSessionBindingState,
|
|
595
|
-
list: listSessionBindingStates
|
|
596
|
-
},
|
|
547
|
+
const bindingManager = new SessionBindingManager(
|
|
548
|
+
{
|
|
549
|
+
load: loadSessionBindingState,
|
|
550
|
+
save: saveSessionBindingState,
|
|
551
|
+
delete: deleteSessionBindingState,
|
|
552
|
+
list: listSessionBindingStates
|
|
553
|
+
},
|
|
597
554
|
sessionBindingBrowser
|
|
598
555
|
);
|
|
599
556
|
|
|
@@ -701,10 +658,10 @@ function normalizeComparableTabUrl(url: string): string {
|
|
|
701
658
|
}
|
|
702
659
|
}
|
|
703
660
|
|
|
704
|
-
async function finalizeOpenedSessionBindingTab(
|
|
705
|
-
opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
|
|
706
|
-
expectedUrl?: string
|
|
707
|
-
): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
|
|
661
|
+
async function finalizeOpenedSessionBindingTab(
|
|
662
|
+
opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
|
|
663
|
+
expectedUrl?: string
|
|
664
|
+
): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
|
|
708
665
|
if (expectedUrl && expectedUrl !== 'about:blank') {
|
|
709
666
|
await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
|
|
710
667
|
}
|
|
@@ -724,9 +681,9 @@ async function finalizeOpenedSessionBindingTab(
|
|
|
724
681
|
url: effectiveUrl
|
|
725
682
|
};
|
|
726
683
|
} catch {
|
|
727
|
-
refreshedTab = (await sessionBindingBrowser.getTab(opened.tab.id)) ?? opened.tab;
|
|
728
|
-
}
|
|
729
|
-
const refreshedBinding = (await bindingManager.getBindingInfo(opened.binding.id)) ?? {
|
|
684
|
+
refreshedTab = (await sessionBindingBrowser.getTab(opened.tab.id)) ?? opened.tab;
|
|
685
|
+
}
|
|
686
|
+
const refreshedBinding = (await bindingManager.getBindingInfo(opened.binding.id)) ?? {
|
|
730
687
|
...opened.binding,
|
|
731
688
|
tabs: opened.binding.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
|
|
732
689
|
};
|
|
@@ -737,11 +694,11 @@ async function finalizeOpenedSessionBindingTab(
|
|
|
737
694
|
};
|
|
738
695
|
}
|
|
739
696
|
|
|
740
|
-
interface WithTabOptions {
|
|
741
|
-
requireSupportedAutomationUrl?: boolean;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
async function withTab(target: { tabId?: number; bindingId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
|
|
697
|
+
interface WithTabOptions {
|
|
698
|
+
requireSupportedAutomationUrl?: boolean;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function withTab(target: { tabId?: number; bindingId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
|
|
745
702
|
const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
|
|
746
703
|
const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
|
|
747
704
|
if (!tab.id) {
|
|
@@ -761,11 +718,11 @@ async function withTab(target: { tabId?: number; bindingId?: string } = {}, opti
|
|
|
761
718
|
return validate(tab);
|
|
762
719
|
}
|
|
763
720
|
|
|
764
|
-
const resolved = await bindingManager.resolveTarget({
|
|
765
|
-
tabId: target.tabId,
|
|
766
|
-
bindingId: typeof target.bindingId === 'string' ? target.bindingId : undefined,
|
|
767
|
-
createIfMissing: false
|
|
768
|
-
});
|
|
721
|
+
const resolved = await bindingManager.resolveTarget({
|
|
722
|
+
tabId: target.tabId,
|
|
723
|
+
bindingId: typeof target.bindingId === 'string' ? target.bindingId : undefined,
|
|
724
|
+
createIfMissing: false
|
|
725
|
+
});
|
|
769
726
|
const tab = await chrome.tabs.get(resolved.tab.id);
|
|
770
727
|
return validate(tab);
|
|
771
728
|
}
|
|
@@ -855,23 +812,23 @@ async function restoreFocusContext(context: FocusContext): Promise<void> {
|
|
|
855
812
|
}
|
|
856
813
|
}
|
|
857
814
|
|
|
858
|
-
async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
|
|
859
|
-
if (!enabled) {
|
|
860
|
-
return action();
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
const focusContext = await captureFocusContext();
|
|
864
|
-
preserveHumanFocusDepth += 1;
|
|
865
|
-
try {
|
|
866
|
-
return await action();
|
|
867
|
-
} finally {
|
|
868
|
-
try {
|
|
869
|
-
await restoreFocusContext(focusContext);
|
|
870
|
-
} finally {
|
|
871
|
-
preserveHumanFocusDepth = Math.max(0, preserveHumanFocusDepth - 1);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
815
|
+
async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
|
|
816
|
+
if (!enabled) {
|
|
817
|
+
return action();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const focusContext = await captureFocusContext();
|
|
821
|
+
preserveHumanFocusDepth += 1;
|
|
822
|
+
try {
|
|
823
|
+
return await action();
|
|
824
|
+
} finally {
|
|
825
|
+
try {
|
|
826
|
+
await restoreFocusContext(focusContext);
|
|
827
|
+
} finally {
|
|
828
|
+
preserveHumanFocusDepth = Math.max(0, preserveHumanFocusDepth - 1);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
875
832
|
|
|
876
833
|
function requireRpcEnvelope(
|
|
877
834
|
method: string,
|
|
@@ -883,11 +840,11 @@ function requireRpcEnvelope(
|
|
|
883
840
|
return value as { ok: boolean; result?: unknown; error?: CliResponse['error'] };
|
|
884
841
|
}
|
|
885
842
|
|
|
886
|
-
async function forwardContentRpc(
|
|
887
|
-
tabId: number,
|
|
888
|
-
method: string,
|
|
889
|
-
params: Record<string, unknown>
|
|
890
|
-
): Promise<unknown> {
|
|
843
|
+
async function forwardContentRpc(
|
|
844
|
+
tabId: number,
|
|
845
|
+
method: string,
|
|
846
|
+
params: Record<string, unknown>
|
|
847
|
+
): Promise<unknown> {
|
|
891
848
|
const raw = await sendToContent<unknown>(tabId, {
|
|
892
849
|
type: 'bak.rpc',
|
|
893
850
|
method,
|
|
@@ -898,1130 +855,1131 @@ async function forwardContentRpc(
|
|
|
898
855
|
if (!response.ok) {
|
|
899
856
|
throw response.error ?? toError('E_INTERNAL', `${method} failed`);
|
|
900
857
|
}
|
|
901
|
-
|
|
902
|
-
return response.result;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
async function ensureTabNetworkCapture(tabId: number): Promise<void> {
|
|
906
|
-
try {
|
|
907
|
-
await ensureNetworkDebugger(tabId);
|
|
908
|
-
} catch (error) {
|
|
909
|
-
throw toError('E_DEBUGGER_NOT_ATTACHED', 'Debugger-backed network capture unavailable', {
|
|
910
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
911
|
-
});
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function normalizePageExecutionScope(value: unknown): PageExecutionScope {
|
|
916
|
-
return value === 'main' || value === 'all-frames' ? value : 'current';
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
async function currentContextFramePath(tabId: number): Promise<string[]> {
|
|
920
|
-
try {
|
|
921
|
-
const context = (await forwardContentRpc(tabId, 'context.get', { tabId })) as { framePath?: string[] };
|
|
922
|
-
return Array.isArray(context.framePath) ? context.framePath.map(String) : [];
|
|
923
|
-
} catch {
|
|
924
|
-
return [];
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
async function executePageWorld<T>(
|
|
929
|
-
tabId: number,
|
|
930
|
-
action: 'eval' | 'extract' | 'fetch',
|
|
931
|
-
params: Record<string, unknown>
|
|
932
|
-
): Promise<{ scope: PageExecutionScope; result?: PageFrameResult<T>; results?: Array<PageFrameResult<T>> }> {
|
|
933
|
-
const scope = normalizePageExecutionScope(params.scope);
|
|
934
|
-
const framePath = scope === 'current' ? await currentContextFramePath(tabId) : [];
|
|
935
|
-
const target: chrome.scripting.InjectionTarget =
|
|
936
|
-
scope === 'all-frames'
|
|
937
|
-
? { tabId, allFrames: true }
|
|
938
|
-
: {
|
|
939
|
-
tabId,
|
|
940
|
-
frameIds: [0]
|
|
941
|
-
};
|
|
942
|
-
|
|
943
|
-
const results = await chrome.scripting.executeScript({
|
|
944
|
-
target,
|
|
945
|
-
world: 'MAIN',
|
|
946
|
-
args: [
|
|
947
|
-
{
|
|
948
|
-
action,
|
|
949
|
-
scope,
|
|
950
|
-
framePath,
|
|
951
|
-
expr: typeof params.expr === 'string' ? params.expr : '',
|
|
952
|
-
path: typeof params.path === 'string' ? params.path : '',
|
|
953
|
-
resolver: typeof params.resolver === 'string' ? params.resolver : undefined,
|
|
954
|
-
url: typeof params.url === 'string' ? params.url : '',
|
|
955
|
-
method: typeof params.method === 'string' ? params.method : 'GET',
|
|
956
|
-
headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
|
|
957
|
-
body: typeof params.body === 'string' ? params.body : undefined,
|
|
958
|
-
contentType: typeof params.contentType === 'string' ? params.contentType : undefined,
|
|
959
|
-
mode: params.mode === 'json' ? 'json' : 'raw',
|
|
960
|
-
maxBytes: typeof params.maxBytes === 'number' ? params.maxBytes : undefined,
|
|
961
|
-
timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
|
|
962
|
-
}
|
|
963
|
-
],
|
|
964
|
-
func: async (payload) => {
|
|
965
|
-
const serializeValue = (value: unknown, maxBytes?: number) => {
|
|
966
|
-
let cloned: unknown;
|
|
967
|
-
try {
|
|
968
|
-
cloned = typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
969
|
-
} catch (error) {
|
|
970
|
-
throw {
|
|
971
|
-
code: 'E_NOT_SERIALIZABLE',
|
|
972
|
-
message: error instanceof Error ? error.message : String(error)
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
const json = JSON.stringify(cloned);
|
|
976
|
-
const bytes = typeof json === 'string' ? json.length : 0;
|
|
977
|
-
if (typeof maxBytes === 'number' && maxBytes > 0 && bytes > maxBytes) {
|
|
978
|
-
throw {
|
|
979
|
-
code: 'E_BODY_TOO_LARGE',
|
|
980
|
-
message: 'serialized value exceeds max-bytes',
|
|
981
|
-
details: { bytes, maxBytes }
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
return { value: cloned, bytes };
|
|
985
|
-
};
|
|
986
|
-
|
|
987
|
-
const parsePath = (path: string): Array<string | number> => {
|
|
988
|
-
if (typeof path !== 'string' || !path.trim()) {
|
|
989
|
-
throw { code: 'E_INVALID_PARAMS', message: 'path is required' };
|
|
990
|
-
}
|
|
991
|
-
const normalized = path.replace(/^globalThis\.?/, '').replace(/^window\.?/, '').trim();
|
|
992
|
-
if (!normalized) {
|
|
993
|
-
return [];
|
|
994
|
-
}
|
|
995
|
-
const segments: Array<string | number> = [];
|
|
996
|
-
let index = 0;
|
|
997
|
-
while (index < normalized.length) {
|
|
998
|
-
if (normalized[index] === '.') {
|
|
999
|
-
index += 1;
|
|
1000
|
-
continue;
|
|
1001
|
-
}
|
|
1002
|
-
if (normalized[index] === '[') {
|
|
1003
|
-
const bracket = normalized.slice(index).match(/^\[(\d+)\]/);
|
|
1004
|
-
if (!bracket) {
|
|
1005
|
-
throw { code: 'E_INVALID_PARAMS', message: 'Only numeric bracket paths are supported' };
|
|
1006
|
-
}
|
|
1007
|
-
segments.push(Number(bracket[1]));
|
|
1008
|
-
index += bracket[0].length;
|
|
1009
|
-
continue;
|
|
1010
|
-
}
|
|
1011
|
-
const identifier = normalized.slice(index).match(/^[A-Za-z_$][\w$]*/);
|
|
1012
|
-
if (!identifier) {
|
|
1013
|
-
throw { code: 'E_INVALID_PARAMS', message: `Unsupported path token near: ${normalized.slice(index, index + 16)}` };
|
|
1014
|
-
}
|
|
1015
|
-
segments.push(identifier[0]);
|
|
1016
|
-
index += identifier[0].length;
|
|
1017
|
-
}
|
|
1018
|
-
return segments;
|
|
1019
|
-
};
|
|
1020
|
-
|
|
1021
|
-
const resolveFrameWindow = (frameSelectors: string[]) => {
|
|
1022
|
-
let currentWindow: Window = window;
|
|
1023
|
-
let currentDocument: Document = document;
|
|
1024
|
-
for (const selector of frameSelectors) {
|
|
1025
|
-
const frame = currentDocument.querySelector(selector);
|
|
1026
|
-
if (!frame || !('contentWindow' in frame)) {
|
|
1027
|
-
throw { code: 'E_NOT_FOUND', message: `frame not found: ${selector}` };
|
|
1028
|
-
}
|
|
1029
|
-
const nextWindow = (frame as HTMLIFrameElement).contentWindow;
|
|
1030
|
-
if (!nextWindow) {
|
|
1031
|
-
throw { code: 'E_NOT_READY', message: `frame window unavailable: ${selector}` };
|
|
1032
|
-
}
|
|
1033
|
-
currentWindow = nextWindow;
|
|
1034
|
-
currentDocument = nextWindow.document;
|
|
1035
|
-
}
|
|
1036
|
-
return currentWindow;
|
|
1037
|
-
};
|
|
1038
|
-
|
|
1039
|
-
const buildPathExpression = (path: string): string =>
|
|
1040
|
-
parsePath(path)
|
|
1041
|
-
.map((segment, index) => {
|
|
1042
|
-
if (typeof segment === 'number') {
|
|
1043
|
-
return `[${segment}]`;
|
|
1044
|
-
}
|
|
1045
|
-
if (index === 0) {
|
|
1046
|
-
return segment;
|
|
1047
|
-
}
|
|
1048
|
-
return `.${segment}`;
|
|
1049
|
-
})
|
|
1050
|
-
.join('');
|
|
1051
|
-
|
|
1052
|
-
const readPath = (targetWindow: Window, path: string): unknown => {
|
|
1053
|
-
const segments = parsePath(path);
|
|
1054
|
-
let current: unknown = targetWindow;
|
|
1055
|
-
for (const segment of segments) {
|
|
1056
|
-
if (current === null || current === undefined || !(segment in (current as Record<string | number, unknown>))) {
|
|
1057
|
-
throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
|
|
1058
|
-
}
|
|
1059
|
-
current = (current as Record<string | number, unknown>)[segment];
|
|
1060
|
-
}
|
|
1061
|
-
return current;
|
|
1062
|
-
};
|
|
1063
|
-
|
|
1064
|
-
const resolveExtractValue = (
|
|
1065
|
-
targetWindow: Window & { eval: (expr: string) => unknown },
|
|
1066
|
-
path: string,
|
|
1067
|
-
resolver: unknown
|
|
1068
|
-
): { resolver: 'globalThis' | 'lexical'; value: unknown } => {
|
|
1069
|
-
const strategy = resolver === 'globalThis' || resolver === 'lexical' ? resolver : 'auto';
|
|
1070
|
-
const lexicalExpression = buildPathExpression(path);
|
|
1071
|
-
const readLexical = (): unknown => {
|
|
1072
|
-
try {
|
|
1073
|
-
return targetWindow.eval(lexicalExpression);
|
|
1074
|
-
} catch (error) {
|
|
1075
|
-
if (error instanceof ReferenceError) {
|
|
1076
|
-
throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
|
|
1077
|
-
}
|
|
1078
|
-
throw error;
|
|
1079
|
-
}
|
|
1080
|
-
};
|
|
1081
|
-
if (strategy === 'globalThis') {
|
|
1082
|
-
return { resolver: 'globalThis', value: readPath(targetWindow, path) };
|
|
1083
|
-
}
|
|
1084
|
-
if (strategy === 'lexical') {
|
|
1085
|
-
return { resolver: 'lexical', value: readLexical() };
|
|
1086
|
-
}
|
|
1087
|
-
try {
|
|
1088
|
-
return { resolver: 'globalThis', value: readPath(targetWindow, path) };
|
|
1089
|
-
} catch (error) {
|
|
1090
|
-
if (typeof error !== 'object' || error === null || (error as { code?: string }).code !== 'E_NOT_FOUND') {
|
|
1091
|
-
throw error;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return { resolver: 'lexical', value: readLexical() };
|
|
1095
|
-
};
|
|
1096
|
-
|
|
1097
|
-
try {
|
|
1098
|
-
const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
|
|
1099
|
-
if (payload.action === 'eval') {
|
|
1100
|
-
const evaluator = (targetWindow as Window & { eval: (expr: string) => unknown }).eval;
|
|
1101
|
-
const serialized = serializeValue(evaluator(payload.expr), payload.maxBytes);
|
|
1102
|
-
return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
|
|
1103
|
-
}
|
|
1104
|
-
if (payload.action === 'extract') {
|
|
1105
|
-
const extracted = resolveExtractValue(targetWindow as Window & { eval: (expr: string) => unknown }, payload.path, payload.resolver);
|
|
1106
|
-
const serialized = serializeValue(extracted.value, payload.maxBytes);
|
|
1107
|
-
return {
|
|
1108
|
-
url: targetWindow.location.href,
|
|
1109
|
-
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
1110
|
-
value: serialized.value,
|
|
1111
|
-
bytes: serialized.bytes,
|
|
1112
|
-
resolver: extracted.resolver
|
|
1113
|
-
};
|
|
1114
|
-
}
|
|
1115
|
-
if (payload.action === 'fetch') {
|
|
1116
|
-
const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
|
|
1117
|
-
if (payload.contentType && !headers['Content-Type']) {
|
|
1118
|
-
headers['Content-Type'] = payload.contentType;
|
|
1119
|
-
}
|
|
1120
|
-
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1121
|
-
const timeoutId =
|
|
1122
|
-
controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
|
|
1123
|
-
? window.setTimeout(() => controller.abort(), payload.timeoutMs)
|
|
1124
|
-
: null;
|
|
1125
|
-
let response: Response;
|
|
1126
|
-
try {
|
|
1127
|
-
response = await targetWindow.fetch(payload.url, {
|
|
1128
|
-
method: payload.method || 'GET',
|
|
1129
|
-
headers,
|
|
1130
|
-
body: typeof payload.body === 'string' ? payload.body : undefined,
|
|
1131
|
-
signal: controller ? controller.signal : undefined
|
|
1132
|
-
});
|
|
1133
|
-
} finally {
|
|
1134
|
-
if (timeoutId !== null) {
|
|
1135
|
-
window.clearTimeout(timeoutId);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
const bodyText = await response.text();
|
|
1139
|
-
const headerMap: Record<string, string> = {};
|
|
1140
|
-
response.headers.forEach((value, key) => {
|
|
1141
|
-
headerMap[key] = value;
|
|
1142
|
-
});
|
|
1143
|
-
return {
|
|
1144
|
-
url: targetWindow.location.href,
|
|
1145
|
-
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
1146
|
-
value: (() => {
|
|
1147
|
-
const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
|
|
1148
|
-
const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
|
|
1149
|
-
const previewLimit = typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
|
|
1150
|
-
const encodedBody = encoder ? encoder.encode(bodyText) : null;
|
|
1151
|
-
const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
|
|
1152
|
-
const truncated = bodyBytes > previewLimit;
|
|
1153
|
-
if (payload.mode === 'json' && truncated) {
|
|
1154
|
-
throw {
|
|
1155
|
-
code: 'E_BODY_TOO_LARGE',
|
|
1156
|
-
message: 'JSON response exceeds max-bytes',
|
|
1157
|
-
details: {
|
|
1158
|
-
bytes: bodyBytes,
|
|
1159
|
-
maxBytes: previewLimit
|
|
1160
|
-
}
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
const previewText =
|
|
1164
|
-
encodedBody && decoder
|
|
1165
|
-
? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
|
|
1166
|
-
: truncated
|
|
1167
|
-
? bodyText.slice(0, previewLimit)
|
|
1168
|
-
: bodyText;
|
|
1169
|
-
return {
|
|
1170
|
-
url: response.url,
|
|
1171
|
-
status: response.status,
|
|
1172
|
-
ok: response.ok,
|
|
1173
|
-
headers: headerMap,
|
|
1174
|
-
contentType: response.headers.get('content-type') ?? undefined,
|
|
1175
|
-
bodyText: payload.mode === 'json' ? undefined : previewText,
|
|
1176
|
-
json: payload.mode === 'json' && bodyText ? JSON.parse(bodyText) : undefined,
|
|
1177
|
-
bytes: bodyBytes,
|
|
1178
|
-
truncated
|
|
1179
|
-
};
|
|
1180
|
-
})()
|
|
1181
|
-
};
|
|
1182
|
-
}
|
|
1183
|
-
throw { code: 'E_NOT_FOUND', message: `Unsupported page world action: ${payload.action}` };
|
|
1184
|
-
} catch (error) {
|
|
1185
|
-
return {
|
|
1186
|
-
url: window.location.href,
|
|
1187
|
-
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
1188
|
-
error:
|
|
1189
|
-
typeof error === 'object' && error !== null && 'code' in error
|
|
1190
|
-
? (error as { code: string; message: string; details?: Record<string, unknown> })
|
|
1191
|
-
: { code: 'E_EXECUTION', message: error instanceof Error ? error.message : String(error) }
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
if (scope === 'all-frames') {
|
|
1198
|
-
return {
|
|
1199
|
-
scope,
|
|
1200
|
-
results: results.map((item) => (item.result ?? { url: '', framePath: [] }) as PageFrameResult<T>)
|
|
1201
|
-
};
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
return {
|
|
1205
|
-
scope,
|
|
1206
|
-
result: (results[0]?.result ?? { url: '', framePath }) as PageFrameResult<T>
|
|
1207
|
-
};
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
function truncateNetworkEntry(entry: NetworkEntry, bodyBytes?: number): NetworkEntry {
|
|
1211
|
-
if (typeof bodyBytes !== 'number' || !Number.isFinite(bodyBytes) || bodyBytes <= 0) {
|
|
1212
|
-
return entry;
|
|
1213
|
-
}
|
|
1214
|
-
const maxBytes = Math.max(1, Math.floor(bodyBytes));
|
|
1215
|
-
const clone: NetworkEntry = { ...entry };
|
|
1216
|
-
if (typeof clone.requestBodyPreview === 'string') {
|
|
1217
|
-
const requestBytes = textEncoder.encode(clone.requestBodyPreview);
|
|
1218
|
-
if (requestBytes.byteLength > maxBytes) {
|
|
1219
|
-
clone.requestBodyPreview = textDecoder.decode(requestBytes.subarray(0, maxBytes));
|
|
1220
|
-
clone.requestBodyTruncated = true;
|
|
1221
|
-
clone.truncated = true;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
if (typeof clone.responseBodyPreview === 'string') {
|
|
1225
|
-
const responseBytes = textEncoder.encode(clone.responseBodyPreview);
|
|
1226
|
-
if (responseBytes.byteLength > maxBytes) {
|
|
1227
|
-
clone.responseBodyPreview = textDecoder.decode(responseBytes.subarray(0, maxBytes));
|
|
1228
|
-
clone.responseBodyTruncated = true;
|
|
1229
|
-
clone.truncated = true;
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
return clone;
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): NetworkEntry {
|
|
1236
|
-
if (!Array.isArray(include)) {
|
|
1237
|
-
return entry;
|
|
1238
|
-
}
|
|
1239
|
-
const sections = new Set(
|
|
1240
|
-
include
|
|
1241
|
-
.map(String)
|
|
1242
|
-
.filter((section): section is 'request' | 'response' => section === 'request' || section === 'response')
|
|
1243
|
-
);
|
|
1244
|
-
if (sections.size === 0 || sections.size === 2) {
|
|
1245
|
-
return entry;
|
|
1246
|
-
}
|
|
1247
|
-
const clone: NetworkEntry = { ...entry };
|
|
1248
|
-
if (!sections.has('request')) {
|
|
1249
|
-
delete clone.requestHeaders;
|
|
1250
|
-
delete clone.requestBodyPreview;
|
|
1251
|
-
delete clone.requestBodyTruncated;
|
|
1252
|
-
}
|
|
1253
|
-
if (!sections.has('response')) {
|
|
1254
|
-
delete clone.responseHeaders;
|
|
1255
|
-
delete clone.responseBodyPreview;
|
|
1256
|
-
delete clone.responseBodyTruncated;
|
|
1257
|
-
delete clone.binary;
|
|
1258
|
-
}
|
|
1259
|
-
return clone;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | undefined {
|
|
1263
|
-
if (!entry.requestHeaders) {
|
|
1264
|
-
return undefined;
|
|
1265
|
-
}
|
|
1266
|
-
const headers: Record<string, string> = {};
|
|
1267
|
-
for (const [name, value] of Object.entries(entry.requestHeaders)) {
|
|
1268
|
-
const normalizedName = name.toLowerCase();
|
|
1269
|
-
if (REPLAY_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
|
|
1270
|
-
continue;
|
|
1271
|
-
}
|
|
1272
|
-
if (containsRedactionMarker(value)) {
|
|
1273
|
-
continue;
|
|
1274
|
-
}
|
|
1275
|
-
headers[name] = value;
|
|
1276
|
-
}
|
|
1277
|
-
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
|
|
1281
|
-
const regexes = (
|
|
1282
|
-
patterns ?? [
|
|
1283
|
-
String.raw`\b20\d{2}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`,
|
|
1284
|
-
String.raw`\b20\d{2}-\d{2}-\d{2}\b`,
|
|
1285
|
-
String.raw`\b20\d{2}\/\d{2}\/\d{2}\b`
|
|
1286
|
-
]
|
|
1287
|
-
).map((pattern) => new RegExp(pattern, 'gi'));
|
|
1288
|
-
const collected = new Map<string, TimestampEvidenceCandidate>();
|
|
1289
|
-
for (const regex of regexes) {
|
|
1290
|
-
for (const match of text.matchAll(regex)) {
|
|
1291
|
-
const value = match[0];
|
|
1292
|
-
if (!value) {
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
const index = match.index ?? text.indexOf(value);
|
|
1296
|
-
const start = Math.max(0, index - 28);
|
|
1297
|
-
const end = Math.min(text.length, index + value.length + 28);
|
|
1298
|
-
const context = text.slice(start, end).replace(/\s+/g, ' ').trim();
|
|
1299
|
-
const key = `${value}::${context}`;
|
|
1300
|
-
if (!collected.has(key)) {
|
|
1301
|
-
collected.set(key, { value, source, context });
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
return [...collected.values()];
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
function parseTimestampCandidate(value: string, now = Date.now()): number | null {
|
|
1309
|
-
const normalized = value.trim().toLowerCase();
|
|
1310
|
-
if (!normalized) {
|
|
1311
|
-
return null;
|
|
1312
|
-
}
|
|
1313
|
-
if (normalized === 'today') {
|
|
1314
|
-
return now;
|
|
1315
|
-
}
|
|
1316
|
-
if (normalized === 'yesterday') {
|
|
1317
|
-
return now - 24 * 60 * 60 * 1000;
|
|
1318
|
-
}
|
|
1319
|
-
const parsed = Date.parse(value);
|
|
1320
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
function nearestPatternDistance(text: string, anchor: string, pattern: RegExp): number | null {
|
|
1324
|
-
const normalizedText = text.toLowerCase();
|
|
1325
|
-
const normalizedAnchor = anchor.toLowerCase();
|
|
1326
|
-
const anchorIndex = normalizedText.indexOf(normalizedAnchor);
|
|
1327
|
-
if (anchorIndex < 0) {
|
|
1328
|
-
return null;
|
|
1329
|
-
}
|
|
1330
|
-
const regex = new RegExp(pattern.source, 'gi');
|
|
1331
|
-
let match: RegExpExecArray | null;
|
|
1332
|
-
let best: number | null = null;
|
|
1333
|
-
while ((match = regex.exec(normalizedText)) !== null) {
|
|
1334
|
-
best = best === null ? Math.abs(anchorIndex - match.index) : Math.min(best, Math.abs(anchorIndex - match.index));
|
|
1335
|
-
}
|
|
1336
|
-
return best;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
function classifyTimestampCandidate(candidate: TimestampEvidenceCandidate, now = Date.now()): PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] {
|
|
1340
|
-
const normalizedPath = (candidate.path ?? '').toLowerCase();
|
|
1341
|
-
if (DATA_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1342
|
-
return 'data';
|
|
1343
|
-
}
|
|
1344
|
-
if (CONTRACT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1345
|
-
return 'contract';
|
|
1346
|
-
}
|
|
1347
|
-
if (EVENT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1348
|
-
return 'event';
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
const context = candidate.context ?? '';
|
|
1352
|
-
const distances = [
|
|
1353
|
-
{ category: 'data' as const, distance: nearestPatternDistance(context, candidate.value, DATA_TIMESTAMP_CONTEXT_PATTERN) },
|
|
1354
|
-
{ category: 'contract' as const, distance: nearestPatternDistance(context, candidate.value, CONTRACT_TIMESTAMP_CONTEXT_PATTERN) },
|
|
1355
|
-
{ category: 'event' as const, distance: nearestPatternDistance(context, candidate.value, EVENT_TIMESTAMP_CONTEXT_PATTERN) }
|
|
1356
|
-
].filter((entry): entry is { category: 'data' | 'contract' | 'event'; distance: number } => typeof entry.distance === 'number');
|
|
1357
|
-
if (distances.length > 0) {
|
|
1358
|
-
distances.sort((left, right) => left.distance - right.distance);
|
|
1359
|
-
return distances[0]!.category;
|
|
1360
|
-
}
|
|
1361
|
-
const parsed = parseTimestampCandidate(candidate.value, now);
|
|
1362
|
-
return typeof parsed === 'number' && parsed > now + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
function normalizeTimestampCandidates(
|
|
1366
|
-
candidates: TimestampEvidenceCandidate[],
|
|
1367
|
-
now = Date.now()
|
|
1368
|
-
): PageFreshnessResult['evidence']['classifiedTimestamps'] {
|
|
1369
|
-
return candidates.map((candidate) => ({
|
|
1370
|
-
value: candidate.value,
|
|
1371
|
-
source: candidate.source,
|
|
1372
|
-
category: candidate.category ?? classifyTimestampCandidate(candidate, now),
|
|
1373
|
-
context: candidate.context,
|
|
1374
|
-
path: candidate.path
|
|
1375
|
-
}));
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
function latestTimestampFromCandidates(
|
|
1379
|
-
candidates: Array<{ value: string; category: PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] }>,
|
|
1380
|
-
now = Date.now()
|
|
1381
|
-
): number | null {
|
|
1382
|
-
let latest: number | null = null;
|
|
1383
|
-
for (const candidate of candidates) {
|
|
1384
|
-
if (candidate.category === 'contract' || candidate.category === 'event') {
|
|
1385
|
-
continue;
|
|
1386
|
-
}
|
|
1387
|
-
const parsed = parseTimestampCandidate(candidate.value, now);
|
|
1388
|
-
if (parsed === null) {
|
|
1389
|
-
continue;
|
|
1390
|
-
}
|
|
1391
|
-
latest = latest === null ? parsed : Math.max(latest, parsed);
|
|
1392
|
-
}
|
|
1393
|
-
return latest;
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
function computeFreshnessAssessment(input: {
|
|
1397
|
-
latestInlineDataTimestamp: number | null;
|
|
1398
|
-
latestPageDataTimestamp: number | null;
|
|
1399
|
-
latestNetworkDataTimestamp: number | null;
|
|
1400
|
-
latestNetworkTimestamp: number | null;
|
|
1401
|
-
domVisibleTimestamp: number | null;
|
|
1402
|
-
lastMutationAt: number | null;
|
|
1403
|
-
freshWindowMs: number;
|
|
1404
|
-
staleWindowMs: number;
|
|
1405
|
-
}): PageFreshnessResult['assessment'] {
|
|
1406
|
-
const now = Date.now();
|
|
1407
|
-
const latestPageVisibleTimestamp = [input.latestPageDataTimestamp, input.latestInlineDataTimestamp, input.domVisibleTimestamp]
|
|
1408
|
-
.filter((value): value is number => typeof value === 'number')
|
|
1409
|
-
.sort((left, right) => right - left)[0] ?? null;
|
|
1410
|
-
if (latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp <= input.freshWindowMs) {
|
|
1411
|
-
return 'fresh';
|
|
1412
|
-
}
|
|
1413
|
-
const networkHasFreshData =
|
|
1414
|
-
typeof input.latestNetworkDataTimestamp === 'number' && now - input.latestNetworkDataTimestamp <= input.freshWindowMs;
|
|
1415
|
-
if (networkHasFreshData) {
|
|
1416
|
-
return 'lagged';
|
|
1417
|
-
}
|
|
1418
|
-
const recentSignals = [input.latestNetworkTimestamp, input.lastMutationAt]
|
|
1419
|
-
.filter((value): value is number => typeof value === 'number')
|
|
1420
|
-
.some((value) => now - value <= input.freshWindowMs);
|
|
1421
|
-
if (recentSignals && latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp > input.freshWindowMs) {
|
|
1422
|
-
return 'lagged';
|
|
1423
|
-
}
|
|
1424
|
-
const staleSignals = [
|
|
1425
|
-
input.latestNetworkTimestamp,
|
|
1426
|
-
input.lastMutationAt,
|
|
1427
|
-
latestPageVisibleTimestamp,
|
|
1428
|
-
input.latestNetworkDataTimestamp
|
|
1429
|
-
]
|
|
1430
|
-
.filter((value): value is number => typeof value === 'number');
|
|
1431
|
-
if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
|
|
1432
|
-
return 'stale';
|
|
1433
|
-
}
|
|
1434
|
-
return 'unknown';
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
async function collectPageInspection(tabId: number, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
1438
|
-
return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as Record<string, unknown>;
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
async function probePageDataCandidatesForTab(tabId: number, inspection: Record<string, unknown>): Promise<PageDataCandidateProbe[]> {
|
|
1442
|
-
const candidateNames = [
|
|
1443
|
-
...(Array.isArray(inspection.suspiciousGlobals) ? inspection.suspiciousGlobals.map(String) : []),
|
|
1444
|
-
...(Array.isArray(inspection.globalsPreview) ? inspection.globalsPreview.map(String) : [])
|
|
1445
|
-
]
|
|
1446
|
-
.filter((name, index, array) => /^[A-Za-z_$][\w$]*$/.test(name) && array.indexOf(name) === index)
|
|
1447
|
-
.slice(0, 16);
|
|
1448
|
-
if (candidateNames.length === 0) {
|
|
1449
|
-
return [];
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
const expr = `(() => {
|
|
1453
|
-
const candidates = ${JSON.stringify(candidateNames)};
|
|
1454
|
-
const dataPattern = /\\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\\b/i;
|
|
1455
|
-
const contractPattern = /\\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\\b/i;
|
|
1456
|
-
const eventPattern = /\\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\\b/i;
|
|
1457
|
-
const isTimestampString = (value) => typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value.trim()));
|
|
1458
|
-
const classify = (path, value) => {
|
|
1459
|
-
const normalized = String(path || '').toLowerCase();
|
|
1460
|
-
if (dataPattern.test(normalized)) return 'data';
|
|
1461
|
-
if (contractPattern.test(normalized)) return 'contract';
|
|
1462
|
-
if (eventPattern.test(normalized)) return 'event';
|
|
1463
|
-
const parsed = Date.parse(String(value || '').trim());
|
|
1464
|
-
return Number.isFinite(parsed) && parsed > Date.now() + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
|
|
1465
|
-
};
|
|
1466
|
-
const sampleValue = (value, depth = 0) => {
|
|
1467
|
-
if (depth >= 2 || value == null || typeof value !== 'object') {
|
|
1468
|
-
if (typeof value === 'string') {
|
|
1469
|
-
return value.length > 160 ? value.slice(0, 160) : value;
|
|
1470
|
-
}
|
|
1471
|
-
if (typeof value === 'function') {
|
|
1472
|
-
return '[Function]';
|
|
1473
|
-
}
|
|
1474
|
-
return value;
|
|
1475
|
-
}
|
|
1476
|
-
if (Array.isArray(value)) {
|
|
1477
|
-
return value.slice(0, 3).map((item) => sampleValue(item, depth + 1));
|
|
1478
|
-
}
|
|
1479
|
-
const sampled = {};
|
|
1480
|
-
for (const key of Object.keys(value).slice(0, 8)) {
|
|
1481
|
-
try {
|
|
1482
|
-
sampled[key] = sampleValue(value[key], depth + 1);
|
|
1483
|
-
} catch {
|
|
1484
|
-
sampled[key] = '[Unreadable]';
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
return sampled;
|
|
1488
|
-
};
|
|
1489
|
-
const collectTimestamps = (value, path, depth, collected) => {
|
|
1490
|
-
if (collected.length >= 16) return;
|
|
1491
|
-
if (isTimestampString(value)) {
|
|
1492
|
-
collected.push({ path, value: String(value), category: classify(path, value) });
|
|
1493
|
-
return;
|
|
1494
|
-
}
|
|
1495
|
-
if (depth >= 3) return;
|
|
1496
|
-
if (Array.isArray(value)) {
|
|
1497
|
-
value.slice(0, 3).forEach((item, index) => collectTimestamps(item, path + '[' + index + ']', depth + 1, collected));
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
if (value && typeof value === 'object') {
|
|
1501
|
-
Object.keys(value)
|
|
1502
|
-
.slice(0, 8)
|
|
1503
|
-
.forEach((key) => {
|
|
1504
|
-
try {
|
|
1505
|
-
collectTimestamps(value[key], path ? path + '.' + key : key, depth + 1, collected);
|
|
1506
|
-
} catch {
|
|
1507
|
-
// Ignore unreadable nested properties.
|
|
1508
|
-
}
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
};
|
|
1512
|
-
const readCandidate = (name) => {
|
|
1513
|
-
if (name in globalThis) {
|
|
1514
|
-
return { resolver: 'globalThis', value: globalThis[name] };
|
|
1515
|
-
}
|
|
1516
|
-
return { resolver: 'lexical', value: globalThis.eval(name) };
|
|
1517
|
-
};
|
|
1518
|
-
const results = [];
|
|
1519
|
-
for (const name of candidates) {
|
|
1520
|
-
try {
|
|
1521
|
-
const resolved = readCandidate(name);
|
|
1522
|
-
const timestamps = [];
|
|
1523
|
-
collectTimestamps(resolved.value, name, 0, timestamps);
|
|
1524
|
-
results.push({
|
|
1525
|
-
name,
|
|
1526
|
-
resolver: resolved.resolver,
|
|
1527
|
-
sample: sampleValue(resolved.value),
|
|
1528
|
-
timestamps
|
|
1529
|
-
});
|
|
1530
|
-
} catch {
|
|
1531
|
-
// Ignore inaccessible candidates.
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
return results;
|
|
1535
|
-
})()`;
|
|
1536
|
-
|
|
1537
|
-
try {
|
|
1538
|
-
const evaluated = await executePageWorld<PageDataCandidateProbe[]>(tabId, 'eval', {
|
|
1539
|
-
expr,
|
|
1540
|
-
scope: 'current',
|
|
1541
|
-
maxBytes: 64 * 1024
|
|
1542
|
-
});
|
|
1543
|
-
const frameResult = evaluated.result ?? evaluated.results?.find((candidate) => candidate.value || candidate.error);
|
|
1544
|
-
return Array.isArray(frameResult?.value) ? frameResult.value : [];
|
|
1545
|
-
} catch {
|
|
1546
|
-
return [];
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
async function buildFreshnessForTab(tabId: number, params: Record<string, unknown> = {}): Promise<PageFreshnessResult> {
|
|
1551
|
-
const inspection = await collectPageInspection(tabId, params);
|
|
1552
|
-
const probedPageDataCandidates = await probePageDataCandidatesForTab(tabId, inspection);
|
|
1553
|
-
const now = Date.now();
|
|
1554
|
-
const freshWindowMs = typeof params.freshWindowMs === 'number' ? Math.max(1, Math.floor(params.freshWindowMs)) : 15 * 60 * 1000;
|
|
1555
|
-
const staleWindowMs = typeof params.staleWindowMs === 'number' ? Math.max(freshWindowMs, Math.floor(params.staleWindowMs)) : 24 * 60 * 60 * 1000;
|
|
1556
|
-
const visibleCandidates = normalizeTimestampCandidates(
|
|
1557
|
-
Array.isArray(inspection.visibleTimestampCandidates)
|
|
1558
|
-
? inspection.visibleTimestampCandidates
|
|
1559
|
-
.filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
|
|
1560
|
-
.map((candidate) => ({
|
|
1561
|
-
value: String(candidate.value ?? ''),
|
|
1562
|
-
context: typeof candidate.context === 'string' ? candidate.context : undefined,
|
|
1563
|
-
source: 'visible' as const
|
|
1564
|
-
}))
|
|
1565
|
-
: Array.isArray(inspection.visibleTimestamps)
|
|
1566
|
-
? inspection.visibleTimestamps.map((value) => ({ value: String(value), source: 'visible' as const }))
|
|
1567
|
-
: [],
|
|
1568
|
-
now
|
|
1569
|
-
);
|
|
1570
|
-
const inlineCandidates = normalizeTimestampCandidates(
|
|
1571
|
-
Array.isArray(inspection.inlineTimestampCandidates)
|
|
1572
|
-
? inspection.inlineTimestampCandidates
|
|
1573
|
-
.filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
|
|
1574
|
-
.map((candidate) => ({
|
|
1575
|
-
value: String(candidate.value ?? ''),
|
|
1576
|
-
context: typeof candidate.context === 'string' ? candidate.context : undefined,
|
|
1577
|
-
source: 'inline' as const
|
|
1578
|
-
}))
|
|
1579
|
-
: Array.isArray(inspection.inlineTimestamps)
|
|
1580
|
-
? inspection.inlineTimestamps.map((value) => ({ value: String(value), source: 'inline' as const }))
|
|
1581
|
-
: [],
|
|
1582
|
-
now
|
|
1583
|
-
);
|
|
1584
|
-
const pageDataCandidates = probedPageDataCandidates.flatMap((candidate) =>
|
|
1585
|
-
Array.isArray(candidate.timestamps)
|
|
1586
|
-
? candidate.timestamps.map((timestamp) => ({
|
|
1587
|
-
value: String(timestamp.value ?? ''),
|
|
1588
|
-
source: 'page-data' as const,
|
|
1589
|
-
path: typeof timestamp.path === 'string' ? timestamp.path : candidate.name,
|
|
1590
|
-
category:
|
|
1591
|
-
timestamp.category === 'data' ||
|
|
1592
|
-
timestamp.category === 'contract' ||
|
|
1593
|
-
timestamp.category === 'event' ||
|
|
1594
|
-
timestamp.category === 'unknown'
|
|
1595
|
-
? timestamp.category
|
|
1596
|
-
: 'unknown'
|
|
1597
|
-
}))
|
|
1598
|
-
: []
|
|
1599
|
-
);
|
|
1600
|
-
const networkEntries = listNetworkEntries(tabId, { limit: 25 });
|
|
1601
|
-
const networkCandidates = normalizeTimestampCandidates(
|
|
1602
|
-
networkEntries.flatMap((entry) => {
|
|
1603
|
-
const previews = [entry.responseBodyPreview, entry.requestBodyPreview].filter((value): value is string => typeof value === 'string');
|
|
1604
|
-
return previews.flatMap((preview) => collectTimestampMatchesFromText(preview, 'network', Array.isArray(params.patterns) ? params.patterns.map(String) : undefined));
|
|
1605
|
-
}),
|
|
1606
|
-
now
|
|
1607
|
-
);
|
|
1608
|
-
const latestInlineDataTimestamp = latestTimestampFromCandidates(inlineCandidates, now);
|
|
1609
|
-
const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
|
|
1610
|
-
const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
|
|
1611
|
-
const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
|
|
1612
|
-
const latestNetworkTs = latestNetworkTimestamp(tabId);
|
|
1613
|
-
const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
|
|
1614
|
-
const allCandidates = [...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates];
|
|
1615
|
-
return {
|
|
1616
|
-
pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
|
|
1617
|
-
lastMutationAt,
|
|
1618
|
-
latestNetworkTimestamp: latestNetworkTs,
|
|
1619
|
-
latestInlineDataTimestamp,
|
|
1620
|
-
latestPageDataTimestamp,
|
|
1621
|
-
latestNetworkDataTimestamp,
|
|
1622
|
-
domVisibleTimestamp,
|
|
1623
|
-
assessment: computeFreshnessAssessment({
|
|
1624
|
-
latestInlineDataTimestamp,
|
|
1625
|
-
latestPageDataTimestamp,
|
|
1626
|
-
latestNetworkDataTimestamp,
|
|
1627
|
-
latestNetworkTimestamp: latestNetworkTs,
|
|
1628
|
-
domVisibleTimestamp,
|
|
1629
|
-
lastMutationAt,
|
|
1630
|
-
freshWindowMs,
|
|
1631
|
-
staleWindowMs
|
|
1632
|
-
}),
|
|
1633
|
-
evidence: {
|
|
1634
|
-
visibleTimestamps: visibleCandidates.map((candidate) => candidate.value),
|
|
1635
|
-
inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
|
|
1636
|
-
pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
|
|
1637
|
-
networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
|
|
1638
|
-
classifiedTimestamps: allCandidates,
|
|
1639
|
-
networkSampleIds: recentNetworkSampleIds(tabId)
|
|
1640
|
-
}
|
|
1641
|
-
};
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
function summarizeNetworkCadence(entries: NetworkEntry[]): Record<string, unknown> {
|
|
1645
|
-
const relevant = entries
|
|
1646
|
-
.filter((entry) => entry.kind === 'fetch' || entry.kind === 'xhr')
|
|
1647
|
-
.slice()
|
|
1648
|
-
.sort((left, right) => left.ts - right.ts);
|
|
1649
|
-
if (relevant.length === 0) {
|
|
1650
|
-
return {
|
|
1651
|
-
sampleCount: 0,
|
|
1652
|
-
classification: 'none',
|
|
1653
|
-
averageIntervalMs: null,
|
|
1654
|
-
medianIntervalMs: null,
|
|
1655
|
-
latestGapMs: null,
|
|
1656
|
-
endpoints: []
|
|
1657
|
-
};
|
|
1658
|
-
}
|
|
1659
|
-
const intervals: number[] = [];
|
|
1660
|
-
for (let index = 1; index < relevant.length; index += 1) {
|
|
1661
|
-
intervals.push(Math.max(0, relevant[index]!.ts - relevant[index - 1]!.ts));
|
|
1662
|
-
}
|
|
1663
|
-
const sortedIntervals = intervals.slice().sort((left, right) => left - right);
|
|
1664
|
-
const averageIntervalMs =
|
|
1665
|
-
intervals.length > 0 ? Math.round(intervals.reduce((sum, value) => sum + value, 0) / intervals.length) : null;
|
|
1666
|
-
const medianIntervalMs =
|
|
1667
|
-
sortedIntervals.length > 0 ? sortedIntervals[Math.floor(sortedIntervals.length / 2)] ?? null : null;
|
|
1668
|
-
const latestGapMs = Math.max(0, Date.now() - relevant[relevant.length - 1]!.ts);
|
|
1669
|
-
const classification =
|
|
1670
|
-
relevant.length >= 3 && medianIntervalMs !== null && medianIntervalMs <= 30_000
|
|
1671
|
-
? 'polling'
|
|
1672
|
-
: relevant.length >= 2
|
|
1673
|
-
? 'bursty'
|
|
1674
|
-
: 'single-request';
|
|
1675
|
-
return {
|
|
1676
|
-
sampleCount: relevant.length,
|
|
1677
|
-
classification,
|
|
1678
|
-
averageIntervalMs,
|
|
1679
|
-
medianIntervalMs,
|
|
1680
|
-
latestGapMs,
|
|
1681
|
-
endpoints: [...new Set(relevant.slice(-5).map((entry) => entry.url))].slice(0, 5)
|
|
1682
|
-
};
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
function extractReplayRowsCandidate(json: unknown): { rows: unknown[]; source: string } | null {
|
|
1686
|
-
if (Array.isArray(json)) {
|
|
1687
|
-
return { rows: json, source: '$' };
|
|
1688
|
-
}
|
|
1689
|
-
if (typeof json !== 'object' || json === null) {
|
|
1690
|
-
return null;
|
|
1691
|
-
}
|
|
1692
|
-
const record = json as Record<string, unknown>;
|
|
1693
|
-
const preferredKeys = ['data', 'rows', 'results', 'items'];
|
|
1694
|
-
for (const key of preferredKeys) {
|
|
1695
|
-
if (Array.isArray(record[key])) {
|
|
1696
|
-
return { rows: record[key] as unknown[], source: `$.${key}` };
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
return null;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
async function enrichReplayWithSchema(tabId: number, response: PageFetchResponse): Promise<PageFetchResponse> {
|
|
1703
|
-
const candidate = extractReplayRowsCandidate(response.json);
|
|
1704
|
-
if (!candidate || candidate.rows.length === 0) {
|
|
1705
|
-
return response;
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
const firstRow = candidate.rows[0];
|
|
1709
|
-
const tablesResult = (await forwardContentRpc(tabId, 'table.list', {})) as { tables?: TableHandle[] };
|
|
1710
|
-
const tables = Array.isArray(tablesResult.tables) ? tablesResult.tables : [];
|
|
1711
|
-
if (tables.length === 0) {
|
|
1712
|
-
return response;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
const schemas: Array<{ table: TableHandle; schema: TableSchema }> = [];
|
|
1716
|
-
for (const table of tables) {
|
|
1717
|
-
const schemaResult = (await forwardContentRpc(tabId, 'table.schema', { table: table.id })) as {
|
|
1718
|
-
table?: TableHandle;
|
|
1719
|
-
schema?: TableSchema;
|
|
1720
|
-
};
|
|
1721
|
-
if (schemaResult.schema && Array.isArray(schemaResult.schema.columns)) {
|
|
1722
|
-
schemas.push({ table: schemaResult.table ?? table, schema: schemaResult.schema });
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
if (schemas.length === 0) {
|
|
1727
|
-
return response;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
if (Array.isArray(firstRow)) {
|
|
1731
|
-
const matchingSchema = schemas.find(({ schema }) => schema.columns.length === firstRow.length) ?? schemas[0];
|
|
1732
|
-
if (!matchingSchema) {
|
|
1733
|
-
return response;
|
|
1734
|
-
}
|
|
1735
|
-
const mappedRows = candidate.rows
|
|
1736
|
-
.filter((row): row is unknown[] => Array.isArray(row))
|
|
1737
|
-
.map((row) => {
|
|
1738
|
-
const mapped: Record<string, unknown> = {};
|
|
1739
|
-
matchingSchema.schema.columns.forEach((column, index) => {
|
|
1740
|
-
mapped[column.label] = row[index];
|
|
1741
|
-
});
|
|
1742
|
-
return mapped;
|
|
1743
|
-
});
|
|
1744
|
-
return {
|
|
1745
|
-
...response,
|
|
1746
|
-
table: matchingSchema.table,
|
|
1747
|
-
schema: matchingSchema.schema,
|
|
1748
|
-
mappedRows,
|
|
1749
|
-
mappingSource: candidate.source
|
|
1750
|
-
};
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
if (typeof firstRow === 'object' && firstRow !== null) {
|
|
1754
|
-
return {
|
|
1755
|
-
...response,
|
|
1756
|
-
mappedRows: candidate.rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null),
|
|
1757
|
-
mappingSource: candidate.source
|
|
1758
|
-
};
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
return response;
|
|
1762
|
-
}
|
|
1763
858
|
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
const target = {
|
|
1767
|
-
tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
|
|
1768
|
-
bindingId: typeof params.bindingId === 'string' ? params.bindingId : undefined
|
|
1769
|
-
};
|
|
1770
|
-
|
|
1771
|
-
const rpcForwardMethods = new Set([
|
|
1772
|
-
'page.title',
|
|
1773
|
-
'page.url',
|
|
1774
|
-
'page.text',
|
|
1775
|
-
'page.dom',
|
|
1776
|
-
'page.accessibilityTree',
|
|
1777
|
-
'page.scrollTo',
|
|
1778
|
-
'page.metrics',
|
|
1779
|
-
'element.hover',
|
|
1780
|
-
'element.doubleClick',
|
|
1781
|
-
'element.rightClick',
|
|
1782
|
-
'element.dragDrop',
|
|
1783
|
-
'element.select',
|
|
1784
|
-
'element.check',
|
|
1785
|
-
'element.uncheck',
|
|
1786
|
-
'element.scrollIntoView',
|
|
1787
|
-
'element.focus',
|
|
1788
|
-
'element.blur',
|
|
1789
|
-
'element.get',
|
|
1790
|
-
'keyboard.press',
|
|
1791
|
-
'keyboard.type',
|
|
1792
|
-
'keyboard.hotkey',
|
|
1793
|
-
'mouse.move',
|
|
1794
|
-
'mouse.click',
|
|
1795
|
-
'mouse.wheel',
|
|
1796
|
-
'file.upload',
|
|
1797
|
-
'context.get',
|
|
1798
|
-
'context.set',
|
|
1799
|
-
'context.enterFrame',
|
|
1800
|
-
'context.exitFrame',
|
|
1801
|
-
'context.enterShadow',
|
|
1802
|
-
'context.exitShadow',
|
|
1803
|
-
'context.reset',
|
|
1804
|
-
'table.list',
|
|
1805
|
-
'table.schema',
|
|
1806
|
-
'table.rows',
|
|
1807
|
-
'table.export'
|
|
1808
|
-
]);
|
|
859
|
+
return response.result;
|
|
860
|
+
}
|
|
1809
861
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
862
|
+
async function ensureTabNetworkCapture(tabId: number): Promise<void> {
|
|
863
|
+
try {
|
|
864
|
+
await ensureNetworkDebugger(tabId);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
throw toError('E_DEBUGGER_NOT_ATTACHED', 'Debugger-backed network capture unavailable', {
|
|
867
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function normalizePageExecutionScope(value: unknown): PageExecutionScope {
|
|
873
|
+
return value === 'main' || value === 'all-frames' ? value : 'current';
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function currentContextFramePath(tabId: number): Promise<string[]> {
|
|
877
|
+
try {
|
|
878
|
+
const context = (await forwardContentRpc(tabId, 'context.get', { tabId })) as { framePath?: string[] };
|
|
879
|
+
return Array.isArray(context.framePath) ? context.framePath.map(String) : [];
|
|
880
|
+
} catch {
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function executePageWorld<T>(
|
|
886
|
+
tabId: number,
|
|
887
|
+
action: 'eval' | 'extract' | 'fetch',
|
|
888
|
+
params: Record<string, unknown>
|
|
889
|
+
): Promise<{ scope: PageExecutionScope; result?: PageFrameResult<T>; results?: Array<PageFrameResult<T>> }> {
|
|
890
|
+
const scope = normalizePageExecutionScope(params.scope);
|
|
891
|
+
const framePath = scope === 'current' ? await currentContextFramePath(tabId) : [];
|
|
892
|
+
const target: chrome.scripting.InjectionTarget =
|
|
893
|
+
scope === 'all-frames'
|
|
894
|
+
? { tabId, allFrames: true }
|
|
895
|
+
: {
|
|
896
|
+
tabId,
|
|
897
|
+
frameIds: [0]
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const results = await chrome.scripting.executeScript({
|
|
901
|
+
target,
|
|
902
|
+
world: 'MAIN',
|
|
903
|
+
args: [
|
|
904
|
+
{
|
|
905
|
+
action,
|
|
906
|
+
scope,
|
|
907
|
+
framePath,
|
|
908
|
+
expr: typeof params.expr === 'string' ? params.expr : '',
|
|
909
|
+
path: typeof params.path === 'string' ? params.path : '',
|
|
910
|
+
resolver: typeof params.resolver === 'string' ? params.resolver : undefined,
|
|
911
|
+
url: typeof params.url === 'string' ? params.url : '',
|
|
912
|
+
method: typeof params.method === 'string' ? params.method : 'GET',
|
|
913
|
+
headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
|
|
914
|
+
body: typeof params.body === 'string' ? params.body : undefined,
|
|
915
|
+
contentType: typeof params.contentType === 'string' ? params.contentType : undefined,
|
|
916
|
+
mode: params.mode === 'json' ? 'json' : 'raw',
|
|
917
|
+
maxBytes: typeof params.maxBytes === 'number' ? params.maxBytes : undefined,
|
|
918
|
+
timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
|
|
1827
919
|
}
|
|
1828
|
-
|
|
1829
|
-
|
|
920
|
+
],
|
|
921
|
+
func: async (payload) => {
|
|
922
|
+
const serializeValue = (value: unknown, maxBytes?: number) => {
|
|
923
|
+
let cloned: unknown;
|
|
924
|
+
try {
|
|
925
|
+
cloned = typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
926
|
+
} catch (error) {
|
|
927
|
+
throw {
|
|
928
|
+
code: 'E_NOT_SERIALIZABLE',
|
|
929
|
+
message: error instanceof Error ? error.message : String(error)
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
const json = JSON.stringify(cloned);
|
|
933
|
+
const bytes = typeof json === 'string' ? json.length : 0;
|
|
934
|
+
if (typeof maxBytes === 'number' && maxBytes > 0 && bytes > maxBytes) {
|
|
935
|
+
throw {
|
|
936
|
+
code: 'E_BODY_TOO_LARGE',
|
|
937
|
+
message: 'serialized value exceeds max-bytes',
|
|
938
|
+
details: { bytes, maxBytes }
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
return { value: cloned, bytes };
|
|
1830
942
|
};
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
943
|
+
|
|
944
|
+
const parsePath = (path: string): Array<string | number> => {
|
|
945
|
+
if (typeof path !== 'string' || !path.trim()) {
|
|
946
|
+
throw { code: 'E_INVALID_PARAMS', message: 'path is required' };
|
|
947
|
+
}
|
|
948
|
+
const normalized = path.replace(/^globalThis\.?/, '').replace(/^window\.?/, '').trim();
|
|
949
|
+
if (!normalized) {
|
|
950
|
+
return [];
|
|
951
|
+
}
|
|
952
|
+
const segments: Array<string | number> = [];
|
|
953
|
+
let index = 0;
|
|
954
|
+
while (index < normalized.length) {
|
|
955
|
+
if (normalized[index] === '.') {
|
|
956
|
+
index += 1;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (normalized[index] === '[') {
|
|
960
|
+
const bracket = normalized.slice(index).match(/^\[(\d+)\]/);
|
|
961
|
+
if (!bracket) {
|
|
962
|
+
throw { code: 'E_INVALID_PARAMS', message: 'Only numeric bracket paths are supported' };
|
|
963
|
+
}
|
|
964
|
+
segments.push(Number(bracket[1]));
|
|
965
|
+
index += bracket[0].length;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const identifier = normalized.slice(index).match(/^[A-Za-z_$][\w$]*/);
|
|
969
|
+
if (!identifier) {
|
|
970
|
+
throw { code: 'E_INVALID_PARAMS', message: `Unsupported path token near: ${normalized.slice(index, index + 16)}` };
|
|
971
|
+
}
|
|
972
|
+
segments.push(identifier[0]);
|
|
973
|
+
index += identifier[0].length;
|
|
974
|
+
}
|
|
975
|
+
return segments;
|
|
1840
976
|
};
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
977
|
+
|
|
978
|
+
const resolveFrameWindow = (frameSelectors: string[]) => {
|
|
979
|
+
let currentWindow: Window = window;
|
|
980
|
+
let currentDocument: Document = document;
|
|
981
|
+
for (const selector of frameSelectors) {
|
|
982
|
+
const frame = currentDocument.querySelector(selector);
|
|
983
|
+
if (!frame || !('contentWindow' in frame)) {
|
|
984
|
+
throw { code: 'E_NOT_FOUND', message: `frame not found: ${selector}` };
|
|
985
|
+
}
|
|
986
|
+
const nextWindow = (frame as HTMLIFrameElement).contentWindow;
|
|
987
|
+
if (!nextWindow) {
|
|
988
|
+
throw { code: 'E_NOT_READY', message: `frame window unavailable: ${selector}` };
|
|
989
|
+
}
|
|
990
|
+
currentWindow = nextWindow;
|
|
991
|
+
currentDocument = nextWindow.document;
|
|
992
|
+
}
|
|
993
|
+
return currentWindow;
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const buildPathExpression = (path: string): string =>
|
|
997
|
+
parsePath(path)
|
|
998
|
+
.map((segment, index) => {
|
|
999
|
+
if (typeof segment === 'number') {
|
|
1000
|
+
return `[${segment}]`;
|
|
1001
|
+
}
|
|
1002
|
+
if (index === 0) {
|
|
1003
|
+
return segment;
|
|
1004
|
+
}
|
|
1005
|
+
return `.${segment}`;
|
|
1006
|
+
})
|
|
1007
|
+
.join('');
|
|
1008
|
+
|
|
1009
|
+
const readPath = (targetWindow: Window, path: string): unknown => {
|
|
1010
|
+
const segments = parsePath(path);
|
|
1011
|
+
let current: unknown = targetWindow;
|
|
1012
|
+
for (const segment of segments) {
|
|
1013
|
+
if (current === null || current === undefined || !(segment in (current as Record<string | number, unknown>))) {
|
|
1014
|
+
throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
|
|
1015
|
+
}
|
|
1016
|
+
current = (current as Record<string | number, unknown>)[segment];
|
|
1017
|
+
}
|
|
1018
|
+
return current;
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const resolveExtractValue = (
|
|
1022
|
+
targetWindow: Window & { eval: (expr: string) => unknown },
|
|
1023
|
+
path: string,
|
|
1024
|
+
resolver: unknown
|
|
1025
|
+
): { resolver: 'globalThis' | 'lexical'; value: unknown } => {
|
|
1026
|
+
const strategy = resolver === 'globalThis' || resolver === 'lexical' ? resolver : 'auto';
|
|
1027
|
+
const lexicalExpression = buildPathExpression(path);
|
|
1028
|
+
const readLexical = (): unknown => {
|
|
1029
|
+
try {
|
|
1030
|
+
return targetWindow.eval(lexicalExpression);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (error instanceof ReferenceError) {
|
|
1033
|
+
throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
|
|
1034
|
+
}
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
if (strategy === 'globalThis') {
|
|
1039
|
+
return { resolver: 'globalThis', value: readPath(targetWindow, path) };
|
|
1040
|
+
}
|
|
1041
|
+
if (strategy === 'lexical') {
|
|
1042
|
+
return { resolver: 'lexical', value: readLexical() };
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
return { resolver: 'globalThis', value: readPath(targetWindow, path) };
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
if (typeof error !== 'object' || error === null || (error as { code?: string }).code !== 'E_NOT_FOUND') {
|
|
1048
|
+
throw error;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return { resolver: 'lexical', value: readLexical() };
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
|
|
1056
|
+
if (payload.action === 'eval') {
|
|
1057
|
+
const evaluator = (targetWindow as Window & { eval: (expr: string) => unknown }).eval;
|
|
1058
|
+
const serialized = serializeValue(evaluator(payload.expr), payload.maxBytes);
|
|
1059
|
+
return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
|
|
1060
|
+
}
|
|
1061
|
+
if (payload.action === 'extract') {
|
|
1062
|
+
const extracted = resolveExtractValue(targetWindow as Window & { eval: (expr: string) => unknown }, payload.path, payload.resolver);
|
|
1063
|
+
const serialized = serializeValue(extracted.value, payload.maxBytes);
|
|
1064
|
+
return {
|
|
1065
|
+
url: targetWindow.location.href,
|
|
1066
|
+
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
1067
|
+
value: serialized.value,
|
|
1068
|
+
bytes: serialized.bytes,
|
|
1069
|
+
resolver: extracted.resolver
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
if (payload.action === 'fetch') {
|
|
1073
|
+
const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
|
|
1074
|
+
if (payload.contentType && !headers['Content-Type']) {
|
|
1075
|
+
headers['Content-Type'] = payload.contentType;
|
|
1076
|
+
}
|
|
1077
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1078
|
+
const timeoutId =
|
|
1079
|
+
controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
|
|
1080
|
+
? window.setTimeout(() => controller.abort(), payload.timeoutMs)
|
|
1081
|
+
: null;
|
|
1082
|
+
let response: Response;
|
|
1083
|
+
try {
|
|
1084
|
+
response = await targetWindow.fetch(payload.url, {
|
|
1085
|
+
method: payload.method || 'GET',
|
|
1086
|
+
headers,
|
|
1087
|
+
body: typeof payload.body === 'string' ? payload.body : undefined,
|
|
1088
|
+
signal: controller ? controller.signal : undefined
|
|
1089
|
+
});
|
|
1090
|
+
} finally {
|
|
1091
|
+
if (timeoutId !== null) {
|
|
1092
|
+
window.clearTimeout(timeoutId);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const bodyText = await response.text();
|
|
1096
|
+
const headerMap: Record<string, string> = {};
|
|
1097
|
+
response.headers.forEach((value, key) => {
|
|
1098
|
+
headerMap[key] = value;
|
|
1099
|
+
});
|
|
1100
|
+
return {
|
|
1101
|
+
url: targetWindow.location.href,
|
|
1102
|
+
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
1103
|
+
value: (() => {
|
|
1104
|
+
const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
|
|
1105
|
+
const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
|
|
1106
|
+
const previewLimit = typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
|
|
1107
|
+
const encodedBody = encoder ? encoder.encode(bodyText) : null;
|
|
1108
|
+
const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
|
|
1109
|
+
const truncated = bodyBytes > previewLimit;
|
|
1110
|
+
if (payload.mode === 'json' && truncated) {
|
|
1111
|
+
throw {
|
|
1112
|
+
code: 'E_BODY_TOO_LARGE',
|
|
1113
|
+
message: 'JSON response exceeds max-bytes',
|
|
1114
|
+
details: {
|
|
1115
|
+
bytes: bodyBytes,
|
|
1116
|
+
maxBytes: previewLimit
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
const previewText =
|
|
1121
|
+
encodedBody && decoder
|
|
1122
|
+
? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
|
|
1123
|
+
: truncated
|
|
1124
|
+
? bodyText.slice(0, previewLimit)
|
|
1125
|
+
: bodyText;
|
|
1126
|
+
return {
|
|
1127
|
+
url: response.url,
|
|
1128
|
+
status: response.status,
|
|
1129
|
+
ok: response.ok,
|
|
1130
|
+
headers: headerMap,
|
|
1131
|
+
contentType: response.headers.get('content-type') ?? undefined,
|
|
1132
|
+
bodyText: payload.mode === 'json' ? undefined : previewText,
|
|
1133
|
+
json: payload.mode === 'json' && bodyText ? JSON.parse(bodyText) : undefined,
|
|
1134
|
+
bytes: bodyBytes,
|
|
1135
|
+
truncated
|
|
1136
|
+
};
|
|
1137
|
+
})()
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
throw { code: 'E_NOT_FOUND', message: `Unsupported page world action: ${payload.action}` };
|
|
1141
|
+
} catch (error) {
|
|
1855
1142
|
return {
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1143
|
+
url: window.location.href,
|
|
1144
|
+
framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
|
|
1145
|
+
error:
|
|
1146
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
1147
|
+
? (error as { code: string; message: string; details?: Record<string, unknown> })
|
|
1148
|
+
: { code: 'E_EXECUTION', message: error instanceof Error ? error.message : String(error) }
|
|
1859
1149
|
};
|
|
1860
1150
|
}
|
|
1861
|
-
return {
|
|
1862
|
-
tabId: tab.id,
|
|
1863
|
-
windowId: tab.windowId
|
|
1864
|
-
};
|
|
1865
1151
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
if (scope === 'all-frames') {
|
|
1155
|
+
return {
|
|
1156
|
+
scope,
|
|
1157
|
+
results: results.map((item) => (item.result ?? { url: '', framePath: [] }) as PageFrameResult<T>)
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
scope,
|
|
1163
|
+
result: (results[0]?.result ?? { url: '', framePath }) as PageFrameResult<T>
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function truncateNetworkEntry(entry: NetworkEntry, bodyBytes?: number): NetworkEntry {
|
|
1168
|
+
if (typeof bodyBytes !== 'number' || !Number.isFinite(bodyBytes) || bodyBytes <= 0) {
|
|
1169
|
+
return entry;
|
|
1170
|
+
}
|
|
1171
|
+
const maxBytes = Math.max(1, Math.floor(bodyBytes));
|
|
1172
|
+
const clone: NetworkEntry = { ...entry };
|
|
1173
|
+
if (typeof clone.requestBodyPreview === 'string') {
|
|
1174
|
+
const requestBytes = textEncoder.encode(clone.requestBodyPreview);
|
|
1175
|
+
if (requestBytes.byteLength > maxBytes) {
|
|
1176
|
+
clone.requestBodyPreview = textDecoder.decode(requestBytes.subarray(0, maxBytes));
|
|
1177
|
+
clone.requestBodyTruncated = true;
|
|
1178
|
+
clone.truncated = true;
|
|
1870
1179
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof clone.responseBodyPreview === 'string') {
|
|
1182
|
+
const responseBytes = textEncoder.encode(clone.responseBodyPreview);
|
|
1183
|
+
if (responseBytes.byteLength > maxBytes) {
|
|
1184
|
+
clone.responseBodyPreview = textDecoder.decode(responseBytes.subarray(0, maxBytes));
|
|
1185
|
+
clone.responseBodyTruncated = true;
|
|
1186
|
+
clone.truncated = true;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return clone;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): NetworkEntry {
|
|
1193
|
+
if (!Array.isArray(include)) {
|
|
1194
|
+
return entry;
|
|
1195
|
+
}
|
|
1196
|
+
const sections = new Set(
|
|
1197
|
+
include
|
|
1198
|
+
.map(String)
|
|
1199
|
+
.filter((section): section is 'request' | 'response' => section === 'request' || section === 'response')
|
|
1200
|
+
);
|
|
1201
|
+
if (sections.size === 0 || sections.size === 2) {
|
|
1202
|
+
return entry;
|
|
1203
|
+
}
|
|
1204
|
+
const clone: NetworkEntry = { ...entry };
|
|
1205
|
+
if (!sections.has('request')) {
|
|
1206
|
+
delete clone.requestHeaders;
|
|
1207
|
+
delete clone.requestBodyPreview;
|
|
1208
|
+
delete clone.requestBodyTruncated;
|
|
1209
|
+
}
|
|
1210
|
+
if (!sections.has('response')) {
|
|
1211
|
+
delete clone.responseHeaders;
|
|
1212
|
+
delete clone.responseBodyPreview;
|
|
1213
|
+
delete clone.responseBodyTruncated;
|
|
1214
|
+
delete clone.binary;
|
|
1215
|
+
}
|
|
1216
|
+
return clone;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | undefined {
|
|
1220
|
+
if (!entry.requestHeaders) {
|
|
1221
|
+
return undefined;
|
|
1222
|
+
}
|
|
1223
|
+
const headers: Record<string, string> = {};
|
|
1224
|
+
for (const [name, value] of Object.entries(entry.requestHeaders)) {
|
|
1225
|
+
const normalizedName = name.toLowerCase();
|
|
1226
|
+
if (REPLAY_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
if (containsRedactionMarker(value)) {
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
headers[name] = value;
|
|
1233
|
+
}
|
|
1234
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
|
|
1238
|
+
const regexes = (
|
|
1239
|
+
patterns ?? [
|
|
1240
|
+
String.raw`\b20\d{2}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`,
|
|
1241
|
+
String.raw`\b20\d{2}-\d{2}-\d{2}\b`,
|
|
1242
|
+
String.raw`\b20\d{2}\/\d{2}\/\d{2}\b`
|
|
1243
|
+
]
|
|
1244
|
+
).map((pattern) => new RegExp(pattern, 'gi'));
|
|
1245
|
+
const collected = new Map<string, TimestampEvidenceCandidate>();
|
|
1246
|
+
for (const regex of regexes) {
|
|
1247
|
+
for (const match of text.matchAll(regex)) {
|
|
1248
|
+
const value = match[0];
|
|
1249
|
+
if (!value) {
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
const index = match.index ?? text.indexOf(value);
|
|
1253
|
+
const start = Math.max(0, index - 28);
|
|
1254
|
+
const end = Math.min(text.length, index + value.length + 28);
|
|
1255
|
+
const context = text.slice(start, end).replace(/\s+/g, ' ').trim();
|
|
1256
|
+
const key = `${value}::${context}`;
|
|
1257
|
+
if (!collected.has(key)) {
|
|
1258
|
+
collected.set(key, { value, source, context });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return [...collected.values()];
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function parseTimestampCandidate(value: string, now = Date.now()): number | null {
|
|
1266
|
+
const normalized = value.trim().toLowerCase();
|
|
1267
|
+
if (!normalized) {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
if (normalized === 'today') {
|
|
1271
|
+
return now;
|
|
1272
|
+
}
|
|
1273
|
+
if (normalized === 'yesterday') {
|
|
1274
|
+
return now - 24 * 60 * 60 * 1000;
|
|
1275
|
+
}
|
|
1276
|
+
const parsed = Date.parse(value);
|
|
1277
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function nearestPatternDistance(text: string, anchor: string, pattern: RegExp): number | null {
|
|
1281
|
+
const normalizedText = text.toLowerCase();
|
|
1282
|
+
const normalizedAnchor = anchor.toLowerCase();
|
|
1283
|
+
const anchorIndex = normalizedText.indexOf(normalizedAnchor);
|
|
1284
|
+
if (anchorIndex < 0) {
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
1288
|
+
let match: RegExpExecArray | null;
|
|
1289
|
+
let best: number | null = null;
|
|
1290
|
+
while ((match = regex.exec(normalizedText)) !== null) {
|
|
1291
|
+
best = best === null ? Math.abs(anchorIndex - match.index) : Math.min(best, Math.abs(anchorIndex - match.index));
|
|
1292
|
+
}
|
|
1293
|
+
return best;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function classifyTimestampCandidate(candidate: TimestampEvidenceCandidate, now = Date.now()): PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] {
|
|
1297
|
+
const normalizedPath = (candidate.path ?? '').toLowerCase();
|
|
1298
|
+
if (DATA_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1299
|
+
return 'data';
|
|
1300
|
+
}
|
|
1301
|
+
if (CONTRACT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1302
|
+
return 'contract';
|
|
1303
|
+
}
|
|
1304
|
+
if (EVENT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
|
|
1305
|
+
return 'event';
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const context = candidate.context ?? '';
|
|
1309
|
+
const distances = [
|
|
1310
|
+
{ category: 'data' as const, distance: nearestPatternDistance(context, candidate.value, DATA_TIMESTAMP_CONTEXT_PATTERN) },
|
|
1311
|
+
{ category: 'contract' as const, distance: nearestPatternDistance(context, candidate.value, CONTRACT_TIMESTAMP_CONTEXT_PATTERN) },
|
|
1312
|
+
{ category: 'event' as const, distance: nearestPatternDistance(context, candidate.value, EVENT_TIMESTAMP_CONTEXT_PATTERN) }
|
|
1313
|
+
].filter((entry): entry is { category: 'data' | 'contract' | 'event'; distance: number } => typeof entry.distance === 'number');
|
|
1314
|
+
if (distances.length > 0) {
|
|
1315
|
+
distances.sort((left, right) => left.distance - right.distance);
|
|
1316
|
+
return distances[0]!.category;
|
|
1317
|
+
}
|
|
1318
|
+
const parsed = parseTimestampCandidate(candidate.value, now);
|
|
1319
|
+
return typeof parsed === 'number' && parsed > now + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function normalizeTimestampCandidates(
|
|
1323
|
+
candidates: TimestampEvidenceCandidate[],
|
|
1324
|
+
now = Date.now()
|
|
1325
|
+
): PageFreshnessResult['evidence']['classifiedTimestamps'] {
|
|
1326
|
+
return candidates.map((candidate) => ({
|
|
1327
|
+
value: candidate.value,
|
|
1328
|
+
source: candidate.source,
|
|
1329
|
+
category: candidate.category ?? classifyTimestampCandidate(candidate, now),
|
|
1330
|
+
context: candidate.context,
|
|
1331
|
+
path: candidate.path
|
|
1332
|
+
}));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function latestTimestampFromCandidates(
|
|
1336
|
+
candidates: Array<{ value: string; category: PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] }>,
|
|
1337
|
+
now = Date.now()
|
|
1338
|
+
): number | null {
|
|
1339
|
+
let latest: number | null = null;
|
|
1340
|
+
for (const candidate of candidates) {
|
|
1341
|
+
if (candidate.category === 'contract' || candidate.category === 'event') {
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
const parsed = parseTimestampCandidate(candidate.value, now);
|
|
1345
|
+
if (parsed === null) {
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
latest = latest === null ? parsed : Math.max(latest, parsed);
|
|
1349
|
+
}
|
|
1350
|
+
return latest;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function computeFreshnessAssessment(input: {
|
|
1354
|
+
latestInlineDataTimestamp: number | null;
|
|
1355
|
+
latestPageDataTimestamp: number | null;
|
|
1356
|
+
latestNetworkDataTimestamp: number | null;
|
|
1357
|
+
latestNetworkTimestamp: number | null;
|
|
1358
|
+
domVisibleTimestamp: number | null;
|
|
1359
|
+
lastMutationAt: number | null;
|
|
1360
|
+
freshWindowMs: number;
|
|
1361
|
+
staleWindowMs: number;
|
|
1362
|
+
}): PageFreshnessResult['assessment'] {
|
|
1363
|
+
const now = Date.now();
|
|
1364
|
+
const latestPageVisibleTimestamp = [input.latestPageDataTimestamp, input.latestInlineDataTimestamp, input.domVisibleTimestamp]
|
|
1365
|
+
.filter((value): value is number => typeof value === 'number')
|
|
1366
|
+
.sort((left, right) => right - left)[0] ?? null;
|
|
1367
|
+
if (latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp <= input.freshWindowMs) {
|
|
1368
|
+
return 'fresh';
|
|
1369
|
+
}
|
|
1370
|
+
const networkHasFreshData =
|
|
1371
|
+
typeof input.latestNetworkDataTimestamp === 'number' && now - input.latestNetworkDataTimestamp <= input.freshWindowMs;
|
|
1372
|
+
if (networkHasFreshData) {
|
|
1373
|
+
return 'lagged';
|
|
1374
|
+
}
|
|
1375
|
+
const recentSignals = [input.latestNetworkTimestamp, input.lastMutationAt]
|
|
1376
|
+
.filter((value): value is number => typeof value === 'number')
|
|
1377
|
+
.some((value) => now - value <= input.freshWindowMs);
|
|
1378
|
+
if (recentSignals && latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp > input.freshWindowMs) {
|
|
1379
|
+
return 'lagged';
|
|
1380
|
+
}
|
|
1381
|
+
const staleSignals = [
|
|
1382
|
+
input.latestNetworkTimestamp,
|
|
1383
|
+
input.lastMutationAt,
|
|
1384
|
+
latestPageVisibleTimestamp,
|
|
1385
|
+
input.latestNetworkDataTimestamp
|
|
1386
|
+
]
|
|
1387
|
+
.filter((value): value is number => typeof value === 'number');
|
|
1388
|
+
if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
|
|
1389
|
+
return 'stale';
|
|
1390
|
+
}
|
|
1391
|
+
return 'unknown';
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
async function collectPageInspection(tabId: number, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
1395
|
+
return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as Record<string, unknown>;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async function probePageDataCandidatesForTab(tabId: number, inspection: Record<string, unknown>): Promise<PageDataCandidateProbe[]> {
|
|
1399
|
+
const candidateNames = [
|
|
1400
|
+
...(Array.isArray(inspection.suspiciousGlobals) ? inspection.suspiciousGlobals.map(String) : []),
|
|
1401
|
+
...(Array.isArray(inspection.globalsPreview) ? inspection.globalsPreview.map(String) : [])
|
|
1402
|
+
]
|
|
1403
|
+
.filter((name, index, array) => /^[A-Za-z_$][\w$]*$/.test(name) && array.indexOf(name) === index)
|
|
1404
|
+
.slice(0, 16);
|
|
1405
|
+
if (candidateNames.length === 0) {
|
|
1406
|
+
return [];
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const expr = `(() => {
|
|
1410
|
+
const candidates = ${JSON.stringify(candidateNames)};
|
|
1411
|
+
const dataPattern = /\\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\\b/i;
|
|
1412
|
+
const contractPattern = /\\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\\b/i;
|
|
1413
|
+
const eventPattern = /\\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\\b/i;
|
|
1414
|
+
const isTimestampString = (value) => typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value.trim()));
|
|
1415
|
+
const classify = (path, value) => {
|
|
1416
|
+
const normalized = String(path || '').toLowerCase();
|
|
1417
|
+
if (dataPattern.test(normalized)) return 'data';
|
|
1418
|
+
if (contractPattern.test(normalized)) return 'contract';
|
|
1419
|
+
if (eventPattern.test(normalized)) return 'event';
|
|
1420
|
+
const parsed = Date.parse(String(value || '').trim());
|
|
1421
|
+
return Number.isFinite(parsed) && parsed > Date.now() + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
|
|
1422
|
+
};
|
|
1423
|
+
const sampleValue = (value, depth = 0) => {
|
|
1424
|
+
if (depth >= 2 || value == null || typeof value !== 'object') {
|
|
1425
|
+
if (typeof value === 'string') {
|
|
1426
|
+
return value.length > 160 ? value.slice(0, 160) : value;
|
|
1427
|
+
}
|
|
1428
|
+
if (typeof value === 'function') {
|
|
1429
|
+
return '[Function]';
|
|
1430
|
+
}
|
|
1431
|
+
return value;
|
|
1432
|
+
}
|
|
1433
|
+
if (Array.isArray(value)) {
|
|
1434
|
+
return value.slice(0, 3).map((item) => sampleValue(item, depth + 1));
|
|
1435
|
+
}
|
|
1436
|
+
const sampled = {};
|
|
1437
|
+
for (const key of Object.keys(value).slice(0, 8)) {
|
|
1438
|
+
try {
|
|
1439
|
+
sampled[key] = sampleValue(value[key], depth + 1);
|
|
1440
|
+
} catch {
|
|
1441
|
+
sampled[key] = '[Unreadable]';
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return sampled;
|
|
1445
|
+
};
|
|
1446
|
+
const collectTimestamps = (value, path, depth, collected) => {
|
|
1447
|
+
if (collected.length >= 16) return;
|
|
1448
|
+
if (isTimestampString(value)) {
|
|
1449
|
+
collected.push({ path, value: String(value), category: classify(path, value) });
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (depth >= 3) return;
|
|
1453
|
+
if (Array.isArray(value)) {
|
|
1454
|
+
value.slice(0, 3).forEach((item, index) => collectTimestamps(item, path + '[' + index + ']', depth + 1, collected));
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (value && typeof value === 'object') {
|
|
1458
|
+
Object.keys(value)
|
|
1459
|
+
.slice(0, 8)
|
|
1460
|
+
.forEach((key) => {
|
|
1461
|
+
try {
|
|
1462
|
+
collectTimestamps(value[key], path ? path + '.' + key : key, depth + 1, collected);
|
|
1463
|
+
} catch {
|
|
1464
|
+
// Ignore unreadable nested properties.
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
const readCandidate = (name) => {
|
|
1470
|
+
if (name in globalThis) {
|
|
1471
|
+
return { resolver: 'globalThis', value: globalThis[name] };
|
|
1472
|
+
}
|
|
1473
|
+
return { resolver: 'lexical', value: globalThis.eval(name) };
|
|
1474
|
+
};
|
|
1475
|
+
const results = [];
|
|
1476
|
+
for (const name of candidates) {
|
|
1477
|
+
try {
|
|
1478
|
+
const resolved = readCandidate(name);
|
|
1479
|
+
const timestamps = [];
|
|
1480
|
+
collectTimestamps(resolved.value, name, 0, timestamps);
|
|
1481
|
+
results.push({
|
|
1482
|
+
name,
|
|
1483
|
+
resolver: resolved.resolver,
|
|
1484
|
+
sample: sampleValue(resolved.value),
|
|
1485
|
+
timestamps
|
|
1486
|
+
});
|
|
1487
|
+
} catch {
|
|
1488
|
+
// Ignore inaccessible candidates.
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return results;
|
|
1492
|
+
})()`;
|
|
1493
|
+
|
|
1494
|
+
try {
|
|
1495
|
+
const evaluated = await executePageWorld<PageDataCandidateProbe[]>(tabId, 'eval', {
|
|
1496
|
+
expr,
|
|
1497
|
+
scope: 'current',
|
|
1498
|
+
maxBytes: 64 * 1024
|
|
1499
|
+
});
|
|
1500
|
+
const frameResult = evaluated.result ?? evaluated.results?.find((candidate) => candidate.value || candidate.error);
|
|
1501
|
+
return Array.isArray(frameResult?.value) ? frameResult.value : [];
|
|
1502
|
+
} catch {
|
|
1503
|
+
return [];
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async function buildFreshnessForTab(tabId: number, params: Record<string, unknown> = {}): Promise<PageFreshnessResult> {
|
|
1508
|
+
const inspection = await collectPageInspection(tabId, params);
|
|
1509
|
+
const probedPageDataCandidates = await probePageDataCandidatesForTab(tabId, inspection);
|
|
1510
|
+
const now = Date.now();
|
|
1511
|
+
const freshWindowMs = typeof params.freshWindowMs === 'number' ? Math.max(1, Math.floor(params.freshWindowMs)) : 15 * 60 * 1000;
|
|
1512
|
+
const staleWindowMs = typeof params.staleWindowMs === 'number' ? Math.max(freshWindowMs, Math.floor(params.staleWindowMs)) : 24 * 60 * 60 * 1000;
|
|
1513
|
+
const visibleCandidates = normalizeTimestampCandidates(
|
|
1514
|
+
Array.isArray(inspection.visibleTimestampCandidates)
|
|
1515
|
+
? inspection.visibleTimestampCandidates
|
|
1516
|
+
.filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
|
|
1517
|
+
.map((candidate) => ({
|
|
1518
|
+
value: String(candidate.value ?? ''),
|
|
1519
|
+
context: typeof candidate.context === 'string' ? candidate.context : undefined,
|
|
1520
|
+
source: 'visible' as const
|
|
1521
|
+
}))
|
|
1522
|
+
: Array.isArray(inspection.visibleTimestamps)
|
|
1523
|
+
? inspection.visibleTimestamps.map((value) => ({ value: String(value), source: 'visible' as const }))
|
|
1524
|
+
: [],
|
|
1525
|
+
now
|
|
1526
|
+
);
|
|
1527
|
+
const inlineCandidates = normalizeTimestampCandidates(
|
|
1528
|
+
Array.isArray(inspection.inlineTimestampCandidates)
|
|
1529
|
+
? inspection.inlineTimestampCandidates
|
|
1530
|
+
.filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
|
|
1531
|
+
.map((candidate) => ({
|
|
1532
|
+
value: String(candidate.value ?? ''),
|
|
1533
|
+
context: typeof candidate.context === 'string' ? candidate.context : undefined,
|
|
1534
|
+
source: 'inline' as const
|
|
1535
|
+
}))
|
|
1536
|
+
: Array.isArray(inspection.inlineTimestamps)
|
|
1537
|
+
? inspection.inlineTimestamps.map((value) => ({ value: String(value), source: 'inline' as const }))
|
|
1538
|
+
: [],
|
|
1539
|
+
now
|
|
1540
|
+
);
|
|
1541
|
+
const pageDataCandidates = probedPageDataCandidates.flatMap((candidate) =>
|
|
1542
|
+
Array.isArray(candidate.timestamps)
|
|
1543
|
+
? candidate.timestamps.map((timestamp) => ({
|
|
1544
|
+
value: String(timestamp.value ?? ''),
|
|
1545
|
+
source: 'page-data' as const,
|
|
1546
|
+
path: typeof timestamp.path === 'string' ? timestamp.path : candidate.name,
|
|
1547
|
+
category:
|
|
1548
|
+
timestamp.category === 'data' ||
|
|
1549
|
+
timestamp.category === 'contract' ||
|
|
1550
|
+
timestamp.category === 'event' ||
|
|
1551
|
+
timestamp.category === 'unknown'
|
|
1552
|
+
? timestamp.category
|
|
1553
|
+
: 'unknown'
|
|
1554
|
+
}))
|
|
1555
|
+
: []
|
|
1556
|
+
);
|
|
1557
|
+
const networkEntries = listNetworkEntries(tabId, { limit: 25 });
|
|
1558
|
+
const networkCandidates = normalizeTimestampCandidates(
|
|
1559
|
+
networkEntries.flatMap((entry) => {
|
|
1560
|
+
const previews = [entry.responseBodyPreview, entry.requestBodyPreview].filter((value): value is string => typeof value === 'string');
|
|
1561
|
+
return previews.flatMap((preview) => collectTimestampMatchesFromText(preview, 'network', Array.isArray(params.patterns) ? params.patterns.map(String) : undefined));
|
|
1562
|
+
}),
|
|
1563
|
+
now
|
|
1564
|
+
);
|
|
1565
|
+
const latestInlineDataTimestamp = latestTimestampFromCandidates(inlineCandidates, now);
|
|
1566
|
+
const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
|
|
1567
|
+
const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
|
|
1568
|
+
const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
|
|
1569
|
+
const latestNetworkTs = latestNetworkTimestamp(tabId);
|
|
1570
|
+
const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
|
|
1571
|
+
const allCandidates = [...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates];
|
|
1572
|
+
return {
|
|
1573
|
+
pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
|
|
1574
|
+
lastMutationAt,
|
|
1575
|
+
latestNetworkTimestamp: latestNetworkTs,
|
|
1576
|
+
latestInlineDataTimestamp,
|
|
1577
|
+
latestPageDataTimestamp,
|
|
1578
|
+
latestNetworkDataTimestamp,
|
|
1579
|
+
domVisibleTimestamp,
|
|
1580
|
+
assessment: computeFreshnessAssessment({
|
|
1581
|
+
latestInlineDataTimestamp,
|
|
1582
|
+
latestPageDataTimestamp,
|
|
1583
|
+
latestNetworkDataTimestamp,
|
|
1584
|
+
latestNetworkTimestamp: latestNetworkTs,
|
|
1585
|
+
domVisibleTimestamp,
|
|
1586
|
+
lastMutationAt,
|
|
1587
|
+
freshWindowMs,
|
|
1588
|
+
staleWindowMs
|
|
1589
|
+
}),
|
|
1590
|
+
evidence: {
|
|
1591
|
+
visibleTimestamps: visibleCandidates.map((candidate) => candidate.value),
|
|
1592
|
+
inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
|
|
1593
|
+
pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
|
|
1594
|
+
networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
|
|
1595
|
+
classifiedTimestamps: allCandidates,
|
|
1596
|
+
networkSampleIds: recentNetworkSampleIds(tabId)
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function summarizeNetworkCadence(entries: NetworkEntry[]): Record<string, unknown> {
|
|
1602
|
+
const relevant = entries
|
|
1603
|
+
.filter((entry) => entry.kind === 'fetch' || entry.kind === 'xhr')
|
|
1604
|
+
.slice()
|
|
1605
|
+
.sort((left, right) => left.ts - right.ts);
|
|
1606
|
+
if (relevant.length === 0) {
|
|
1607
|
+
return {
|
|
1608
|
+
sampleCount: 0,
|
|
1609
|
+
classification: 'none',
|
|
1610
|
+
averageIntervalMs: null,
|
|
1611
|
+
medianIntervalMs: null,
|
|
1612
|
+
latestGapMs: null,
|
|
1613
|
+
endpoints: []
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
const intervals: number[] = [];
|
|
1617
|
+
for (let index = 1; index < relevant.length; index += 1) {
|
|
1618
|
+
intervals.push(Math.max(0, relevant[index]!.ts - relevant[index - 1]!.ts));
|
|
1619
|
+
}
|
|
1620
|
+
const sortedIntervals = intervals.slice().sort((left, right) => left - right);
|
|
1621
|
+
const averageIntervalMs =
|
|
1622
|
+
intervals.length > 0 ? Math.round(intervals.reduce((sum, value) => sum + value, 0) / intervals.length) : null;
|
|
1623
|
+
const medianIntervalMs =
|
|
1624
|
+
sortedIntervals.length > 0 ? sortedIntervals[Math.floor(sortedIntervals.length / 2)] ?? null : null;
|
|
1625
|
+
const latestGapMs = Math.max(0, Date.now() - relevant[relevant.length - 1]!.ts);
|
|
1626
|
+
const classification =
|
|
1627
|
+
relevant.length >= 3 && medianIntervalMs !== null && medianIntervalMs <= 30_000
|
|
1628
|
+
? 'polling'
|
|
1629
|
+
: relevant.length >= 2
|
|
1630
|
+
? 'bursty'
|
|
1631
|
+
: 'single-request';
|
|
1632
|
+
return {
|
|
1633
|
+
sampleCount: relevant.length,
|
|
1634
|
+
classification,
|
|
1635
|
+
averageIntervalMs,
|
|
1636
|
+
medianIntervalMs,
|
|
1637
|
+
latestGapMs,
|
|
1638
|
+
endpoints: [...new Set(relevant.slice(-5).map((entry) => entry.url))].slice(0, 5)
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function extractReplayRowsCandidate(json: unknown): { rows: unknown[]; source: string } | null {
|
|
1643
|
+
if (Array.isArray(json)) {
|
|
1644
|
+
return { rows: json, source: '$' };
|
|
1645
|
+
}
|
|
1646
|
+
if (typeof json !== 'object' || json === null) {
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
const record = json as Record<string, unknown>;
|
|
1650
|
+
const preferredKeys = ['data', 'rows', 'results', 'items'];
|
|
1651
|
+
for (const key of preferredKeys) {
|
|
1652
|
+
if (Array.isArray(record[key])) {
|
|
1653
|
+
return { rows: record[key] as unknown[], source: `$.${key}` };
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return null;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function enrichReplayWithSchema(tabId: number, response: PageFetchResponse): Promise<PageFetchResponse> {
|
|
1660
|
+
const candidate = extractReplayRowsCandidate(response.json);
|
|
1661
|
+
if (!candidate || candidate.rows.length === 0) {
|
|
1662
|
+
return response;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const firstRow = candidate.rows[0];
|
|
1666
|
+
const tablesResult = (await forwardContentRpc(tabId, 'table.list', {})) as { tables?: TableHandle[] };
|
|
1667
|
+
const tables = Array.isArray(tablesResult.tables) ? tablesResult.tables : [];
|
|
1668
|
+
if (tables.length === 0) {
|
|
1669
|
+
return response;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const schemas: Array<{ table: TableHandle; schema: TableSchema }> = [];
|
|
1673
|
+
for (const table of tables) {
|
|
1674
|
+
const schemaResult = (await forwardContentRpc(tabId, 'table.schema', { table: table.id })) as {
|
|
1675
|
+
table?: TableHandle;
|
|
1676
|
+
schema?: TableSchema;
|
|
1677
|
+
};
|
|
1678
|
+
if (schemaResult.schema && Array.isArray(schemaResult.schema.columns)) {
|
|
1679
|
+
schemas.push({ table: schemaResult.table ?? table, schema: schemaResult.schema });
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (schemas.length === 0) {
|
|
1684
|
+
return response;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (Array.isArray(firstRow)) {
|
|
1688
|
+
const matchingSchema = schemas.find(({ schema }) => schema.columns.length === firstRow.length) ?? schemas[0];
|
|
1689
|
+
if (!matchingSchema) {
|
|
1690
|
+
return response;
|
|
1691
|
+
}
|
|
1692
|
+
const mappedRows = candidate.rows
|
|
1693
|
+
.filter((row): row is unknown[] => Array.isArray(row))
|
|
1694
|
+
.map((row) => {
|
|
1695
|
+
const mapped: Record<string, unknown> = {};
|
|
1696
|
+
matchingSchema.schema.columns.forEach((column, index) => {
|
|
1697
|
+
mapped[column.label] = row[index];
|
|
1698
|
+
});
|
|
1699
|
+
return mapped;
|
|
1700
|
+
});
|
|
1701
|
+
return {
|
|
1702
|
+
...response,
|
|
1703
|
+
table: matchingSchema.table,
|
|
1704
|
+
schema: matchingSchema.schema,
|
|
1705
|
+
mappedRows,
|
|
1706
|
+
mappingSource: candidate.source
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (typeof firstRow === 'object' && firstRow !== null) {
|
|
1711
|
+
return {
|
|
1712
|
+
...response,
|
|
1713
|
+
mappedRows: candidate.rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null),
|
|
1714
|
+
mappingSource: candidate.source
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
return response;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
1722
|
+
const params = request.params ?? {};
|
|
1723
|
+
const target = {
|
|
1724
|
+
tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
|
|
1725
|
+
bindingId: typeof params.bindingId === 'string' ? params.bindingId : undefined
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
const rpcForwardMethods = new Set([
|
|
1729
|
+
'page.title',
|
|
1730
|
+
'page.url',
|
|
1731
|
+
'page.text',
|
|
1732
|
+
'page.dom',
|
|
1733
|
+
'page.accessibilityTree',
|
|
1734
|
+
'page.scrollTo',
|
|
1735
|
+
'page.metrics',
|
|
1736
|
+
'element.hover',
|
|
1737
|
+
'element.doubleClick',
|
|
1738
|
+
'element.rightClick',
|
|
1739
|
+
'element.dragDrop',
|
|
1740
|
+
'element.select',
|
|
1741
|
+
'element.check',
|
|
1742
|
+
'element.uncheck',
|
|
1743
|
+
'element.scrollIntoView',
|
|
1744
|
+
'element.focus',
|
|
1745
|
+
'element.blur',
|
|
1746
|
+
'element.get',
|
|
1747
|
+
'keyboard.press',
|
|
1748
|
+
'keyboard.type',
|
|
1749
|
+
'keyboard.hotkey',
|
|
1750
|
+
'mouse.move',
|
|
1751
|
+
'mouse.click',
|
|
1752
|
+
'mouse.wheel',
|
|
1753
|
+
'file.upload',
|
|
1754
|
+
'context.get',
|
|
1755
|
+
'context.set',
|
|
1756
|
+
'context.enterFrame',
|
|
1757
|
+
'context.exitFrame',
|
|
1758
|
+
'context.enterShadow',
|
|
1759
|
+
'context.exitShadow',
|
|
1760
|
+
'context.reset',
|
|
1761
|
+
'table.list',
|
|
1762
|
+
'table.schema',
|
|
1763
|
+
'table.rows',
|
|
1764
|
+
'table.export'
|
|
1765
|
+
]);
|
|
1766
|
+
|
|
1767
|
+
switch (request.method) {
|
|
1768
|
+
case 'session.ping': {
|
|
1769
|
+
return { ok: true, ts: Date.now() };
|
|
1770
|
+
}
|
|
1771
|
+
case 'tabs.list': {
|
|
1772
|
+
const tabs = await chrome.tabs.query({});
|
|
1773
|
+
return {
|
|
1774
|
+
tabs: tabs
|
|
1775
|
+
.filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
|
|
1776
|
+
.map((tab) => toTabInfo(tab))
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
case 'tabs.getActive': {
|
|
1780
|
+
const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
1781
|
+
const tab = tabs[0];
|
|
1782
|
+
if (!tab || typeof tab.id !== 'number') {
|
|
1783
|
+
return { tab: null };
|
|
1784
|
+
}
|
|
1785
|
+
return {
|
|
1786
|
+
tab: toTabInfo(tab)
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
case 'tabs.get': {
|
|
1790
|
+
const tabId = Number(params.tabId);
|
|
1791
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1792
|
+
if (typeof tab.id !== 'number') {
|
|
1793
|
+
throw toError('E_NOT_FOUND', 'Tab missing id');
|
|
1794
|
+
}
|
|
1795
|
+
return {
|
|
1796
|
+
tab: toTabInfo(tab)
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
case 'tabs.focus': {
|
|
1800
|
+
const tabId = Number(params.tabId);
|
|
1801
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
1802
|
+
return { ok: true };
|
|
1803
|
+
}
|
|
1804
|
+
case 'tabs.new': {
|
|
1805
|
+
const tab = await chrome.tabs.create({
|
|
1806
|
+
url: (params.url as string | undefined) ?? 'about:blank',
|
|
1807
|
+
windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
|
|
1808
|
+
active: params.active === true
|
|
1809
|
+
});
|
|
1810
|
+
if (params.addToGroup === true && typeof tab.id === 'number') {
|
|
1811
|
+
const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
|
1812
|
+
return {
|
|
1813
|
+
tabId: tab.id,
|
|
1814
|
+
windowId: tab.windowId,
|
|
1815
|
+
groupId
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
return {
|
|
1819
|
+
tabId: tab.id,
|
|
1820
|
+
windowId: tab.windowId
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
case 'tabs.close': {
|
|
1824
|
+
const tabId = Number(params.tabId);
|
|
1825
|
+
await chrome.tabs.remove(tabId);
|
|
1826
|
+
return { ok: true };
|
|
1827
|
+
}
|
|
1828
|
+
case 'sessionBinding.ensure': {
|
|
1829
|
+
return preserveHumanFocus(params.focus !== true, async () => {
|
|
1830
|
+
const result = await bindingManager.ensureBinding({
|
|
1831
|
+
bindingId: String(params.bindingId ?? ''),
|
|
1832
|
+
focus: params.focus === true,
|
|
1833
|
+
initialUrl: typeof params.url === 'string' ? params.url : undefined,
|
|
1834
|
+
label: typeof params.label === 'string' ? params.label : undefined
|
|
1835
|
+
});
|
|
1836
|
+
for (const tab of result.binding.tabs) {
|
|
1837
|
+
void ensureNetworkDebugger(tab.id).catch(() => undefined);
|
|
1838
|
+
}
|
|
1839
|
+
emitSessionBindingUpdated(result.binding.id, 'ensure', result.binding);
|
|
1840
|
+
return {
|
|
1841
|
+
browser: result.binding,
|
|
1842
|
+
created: result.created,
|
|
1843
|
+
repaired: result.repaired,
|
|
1844
|
+
repairActions: result.repairActions
|
|
1845
|
+
};
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
case 'sessionBinding.info': {
|
|
1849
|
+
return {
|
|
1850
|
+
browser: await bindingManager.getBindingInfo(String(params.bindingId ?? ''))
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
case 'sessionBinding.openTab': {
|
|
1854
|
+
const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
|
|
1855
|
+
const opened = await preserveHumanFocus(params.focus !== true, async () => {
|
|
1856
|
+
return await bindingManager.openTab({
|
|
1857
|
+
bindingId: String(params.bindingId ?? ''),
|
|
1858
|
+
url: expectedUrl,
|
|
1859
|
+
active: params.active === true,
|
|
1860
|
+
focus: params.focus === true,
|
|
1861
|
+
label: typeof params.label === 'string' ? params.label : undefined
|
|
1862
|
+
});
|
|
1863
|
+
});
|
|
1864
|
+
const finalized = await finalizeOpenedSessionBindingTab(opened, expectedUrl);
|
|
1865
|
+
void ensureNetworkDebugger(finalized.tab.id).catch(() => undefined);
|
|
1866
|
+
emitSessionBindingUpdated(finalized.binding.id, 'open-tab', finalized.binding);
|
|
1867
|
+
return {
|
|
1868
|
+
browser: finalized.binding,
|
|
1869
|
+
tab: finalized.tab
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
case 'sessionBinding.listTabs': {
|
|
1873
|
+
const listed = await bindingManager.listTabs(String(params.bindingId ?? ''));
|
|
1874
|
+
return {
|
|
1875
|
+
browser: listed.binding,
|
|
1876
|
+
tabs: listed.tabs
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
case 'sessionBinding.getActiveTab': {
|
|
1880
|
+
const active = await bindingManager.getActiveTab(String(params.bindingId ?? ''));
|
|
1881
|
+
return {
|
|
1882
|
+
browser: active.binding,
|
|
1883
|
+
tab: active.tab
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
case 'sessionBinding.setActiveTab': {
|
|
1887
|
+
const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.bindingId ?? ''));
|
|
1888
|
+
void ensureNetworkDebugger(result.tab.id).catch(() => undefined);
|
|
1889
|
+
emitSessionBindingUpdated(result.binding.id, 'set-active-tab', result.binding);
|
|
1890
|
+
return {
|
|
1891
|
+
browser: result.binding,
|
|
1892
|
+
tab: result.tab
|
|
1893
|
+
};
|
|
1937
1894
|
}
|
|
1938
1895
|
case 'sessionBinding.focus': {
|
|
1939
1896
|
const result = await bindingManager.focus(String(params.bindingId ?? ''));
|
|
1897
|
+
emitSessionBindingUpdated(result.binding.id, 'focus', result.binding);
|
|
1940
1898
|
return {
|
|
1941
1899
|
ok: true,
|
|
1942
1900
|
browser: result.binding
|
|
1943
1901
|
};
|
|
1944
1902
|
}
|
|
1945
|
-
case 'sessionBinding.reset': {
|
|
1946
|
-
return await preserveHumanFocus(params.focus !== true, async () => {
|
|
1947
|
-
const result = await bindingManager.reset({
|
|
1948
|
-
bindingId: String(params.bindingId ?? ''),
|
|
1949
|
-
focus: params.focus === true,
|
|
1950
|
-
initialUrl: typeof params.url === 'string' ? params.url : undefined,
|
|
1951
|
-
label: typeof params.label === 'string' ? params.label : undefined
|
|
1952
|
-
});
|
|
1953
|
-
emitSessionBindingUpdated(result.binding.id, 'reset', result.binding);
|
|
1954
|
-
return {
|
|
1955
|
-
browser: result.binding,
|
|
1956
|
-
created: result.created,
|
|
1957
|
-
repaired: result.repaired,
|
|
1958
|
-
repairActions: result.repairActions
|
|
1959
|
-
};
|
|
1960
|
-
});
|
|
1961
|
-
}
|
|
1962
|
-
case 'sessionBinding.closeTab': {
|
|
1963
|
-
const bindingId = String(params.bindingId ?? '');
|
|
1964
|
-
const result = await bindingManager.closeTab(bindingId, typeof params.tabId === 'number' ? params.tabId : undefined);
|
|
1965
|
-
emitSessionBindingUpdated(bindingId, 'close-tab', result.binding, {
|
|
1966
|
-
closedTabId: result.closedTabId
|
|
1967
|
-
});
|
|
1968
|
-
return {
|
|
1969
|
-
browser: result.binding,
|
|
1970
|
-
closedTabId: result.closedTabId
|
|
1971
|
-
};
|
|
1972
|
-
}
|
|
1973
|
-
case 'sessionBinding.close': {
|
|
1974
|
-
const bindingId = String(params.bindingId ?? '');
|
|
1975
|
-
const result = await bindingManager.close(bindingId);
|
|
1976
|
-
emitSessionBindingUpdated(bindingId, 'close', null);
|
|
1977
|
-
return result;
|
|
1978
|
-
}
|
|
1979
|
-
case 'page.goto': {
|
|
1980
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1981
|
-
const tab = await withTab(target, {
|
|
1982
|
-
requireSupportedAutomationUrl: false
|
|
1983
|
-
});
|
|
1984
|
-
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1985
|
-
const url = String(params.url ?? 'about:blank');
|
|
1986
|
-
await chrome.tabs.update(tab.id!, { url });
|
|
1903
|
+
case 'sessionBinding.reset': {
|
|
1904
|
+
return await preserveHumanFocus(params.focus !== true, async () => {
|
|
1905
|
+
const result = await bindingManager.reset({
|
|
1906
|
+
bindingId: String(params.bindingId ?? ''),
|
|
1907
|
+
focus: params.focus === true,
|
|
1908
|
+
initialUrl: typeof params.url === 'string' ? params.url : undefined,
|
|
1909
|
+
label: typeof params.label === 'string' ? params.label : undefined
|
|
1910
|
+
});
|
|
1911
|
+
emitSessionBindingUpdated(result.binding.id, 'reset', result.binding);
|
|
1912
|
+
return {
|
|
1913
|
+
browser: result.binding,
|
|
1914
|
+
created: result.created,
|
|
1915
|
+
repaired: result.repaired,
|
|
1916
|
+
repairActions: result.repairActions
|
|
1917
|
+
};
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
case 'sessionBinding.closeTab': {
|
|
1921
|
+
const bindingId = String(params.bindingId ?? '');
|
|
1922
|
+
const result = await bindingManager.closeTab(bindingId, typeof params.tabId === 'number' ? params.tabId : undefined);
|
|
1923
|
+
emitSessionBindingUpdated(bindingId, 'close-tab', result.binding, {
|
|
1924
|
+
closedTabId: result.closedTabId
|
|
1925
|
+
});
|
|
1926
|
+
return {
|
|
1927
|
+
browser: result.binding,
|
|
1928
|
+
closedTabId: result.closedTabId
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
case 'sessionBinding.close': {
|
|
1932
|
+
const bindingId = String(params.bindingId ?? '');
|
|
1933
|
+
const result = await bindingManager.close(bindingId);
|
|
1934
|
+
emitSessionBindingUpdated(bindingId, 'close', null);
|
|
1935
|
+
return result;
|
|
1936
|
+
}
|
|
1937
|
+
case 'page.goto': {
|
|
1938
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1939
|
+
const tab = await withTab(target, {
|
|
1940
|
+
requireSupportedAutomationUrl: false
|
|
1941
|
+
});
|
|
1942
|
+
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1943
|
+
const url = String(params.url ?? 'about:blank');
|
|
1944
|
+
await chrome.tabs.update(tab.id!, { url });
|
|
1987
1945
|
await waitForTabUrl(tab.id!, url);
|
|
1988
1946
|
await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
|
|
1989
1947
|
await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
|
|
1990
1948
|
return { ok: true };
|
|
1991
1949
|
});
|
|
1992
1950
|
}
|
|
1993
|
-
case 'page.back': {
|
|
1994
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1995
|
-
const tab = await withTab(target);
|
|
1996
|
-
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1997
|
-
await chrome.tabs.goBack(tab.id!);
|
|
1951
|
+
case 'page.back': {
|
|
1952
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1953
|
+
const tab = await withTab(target);
|
|
1954
|
+
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1955
|
+
await chrome.tabs.goBack(tab.id!);
|
|
1998
1956
|
await waitForTabComplete(tab.id!);
|
|
1999
1957
|
return { ok: true };
|
|
2000
1958
|
});
|
|
2001
1959
|
}
|
|
2002
|
-
case 'page.forward': {
|
|
2003
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2004
|
-
const tab = await withTab(target);
|
|
2005
|
-
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2006
|
-
await chrome.tabs.goForward(tab.id!);
|
|
1960
|
+
case 'page.forward': {
|
|
1961
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1962
|
+
const tab = await withTab(target);
|
|
1963
|
+
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1964
|
+
await chrome.tabs.goForward(tab.id!);
|
|
2007
1965
|
await waitForTabComplete(tab.id!);
|
|
2008
1966
|
return { ok: true };
|
|
2009
1967
|
});
|
|
2010
1968
|
}
|
|
2011
|
-
case 'page.reload': {
|
|
2012
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2013
|
-
const tab = await withTab(target);
|
|
2014
|
-
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2015
|
-
await chrome.tabs.reload(tab.id!);
|
|
1969
|
+
case 'page.reload': {
|
|
1970
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1971
|
+
const tab = await withTab(target);
|
|
1972
|
+
void ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
1973
|
+
await chrome.tabs.reload(tab.id!);
|
|
2016
1974
|
await waitForTabComplete(tab.id!);
|
|
2017
1975
|
return { ok: true };
|
|
2018
1976
|
});
|
|
2019
1977
|
}
|
|
2020
|
-
case 'page.viewport': {
|
|
2021
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2022
|
-
const tab = await withTab(target, {
|
|
2023
|
-
requireSupportedAutomationUrl: false
|
|
2024
|
-
});
|
|
1978
|
+
case 'page.viewport': {
|
|
1979
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
1980
|
+
const tab = await withTab(target, {
|
|
1981
|
+
requireSupportedAutomationUrl: false
|
|
1982
|
+
});
|
|
2025
1983
|
if (typeof tab.windowId !== 'number') {
|
|
2026
1984
|
throw toError('E_NOT_FOUND', 'Tab window unavailable');
|
|
2027
1985
|
}
|
|
@@ -2046,31 +2004,31 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
2046
2004
|
width: viewWidth,
|
|
2047
2005
|
height: viewHeight,
|
|
2048
2006
|
devicePixelRatio: viewport.devicePixelRatio
|
|
2049
|
-
};
|
|
2050
|
-
});
|
|
2051
|
-
}
|
|
2052
|
-
case 'page.eval': {
|
|
2053
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2054
|
-
const tab = await withTab(target);
|
|
2055
|
-
return await executePageWorld(tab.id!, 'eval', params);
|
|
2056
|
-
});
|
|
2057
|
-
}
|
|
2058
|
-
case 'page.extract': {
|
|
2059
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2060
|
-
const tab = await withTab(target);
|
|
2061
|
-
return await executePageWorld(tab.id!, 'extract', params);
|
|
2062
|
-
});
|
|
2063
|
-
}
|
|
2064
|
-
case 'page.fetch': {
|
|
2065
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2066
|
-
const tab = await withTab(target);
|
|
2067
|
-
return await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', params);
|
|
2068
|
-
});
|
|
2069
|
-
}
|
|
2070
|
-
case 'page.snapshot': {
|
|
2071
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2072
|
-
const tab = await withTab(target);
|
|
2073
|
-
if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
|
|
2007
|
+
};
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
case 'page.eval': {
|
|
2011
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2012
|
+
const tab = await withTab(target);
|
|
2013
|
+
return await executePageWorld(tab.id!, 'eval', params);
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
case 'page.extract': {
|
|
2017
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2018
|
+
const tab = await withTab(target);
|
|
2019
|
+
return await executePageWorld(tab.id!, 'extract', params);
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
case 'page.fetch': {
|
|
2023
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2024
|
+
const tab = await withTab(target);
|
|
2025
|
+
return await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', params);
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
case 'page.snapshot': {
|
|
2029
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2030
|
+
const tab = await withTab(target);
|
|
2031
|
+
if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
|
|
2074
2032
|
throw toError('E_NOT_FOUND', 'Tab missing id');
|
|
2075
2033
|
}
|
|
2076
2034
|
const includeBase64 = params.includeBase64 !== false;
|
|
@@ -2151,297 +2109,297 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
2151
2109
|
return { ok: true };
|
|
2152
2110
|
});
|
|
2153
2111
|
}
|
|
2154
|
-
case 'debug.getConsole': {
|
|
2155
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2156
|
-
const tab = await withTab(target);
|
|
2157
|
-
const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
|
|
2158
|
-
type: 'bak.getConsole',
|
|
2159
|
-
limit: Number(params.limit ?? 50)
|
|
2160
|
-
});
|
|
2161
|
-
return { entries: response.entries };
|
|
2162
|
-
});
|
|
2163
|
-
}
|
|
2164
|
-
case 'network.list': {
|
|
2165
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2166
|
-
const tab = await withTab(target);
|
|
2167
|
-
try {
|
|
2168
|
-
await ensureTabNetworkCapture(tab.id!);
|
|
2169
|
-
return {
|
|
2170
|
-
entries: listNetworkEntries(tab.id!, {
|
|
2171
|
-
limit: typeof params.limit === 'number' ? params.limit : undefined,
|
|
2172
|
-
urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
|
|
2173
|
-
status: typeof params.status === 'number' ? params.status : undefined,
|
|
2174
|
-
method: typeof params.method === 'string' ? params.method : undefined
|
|
2175
|
-
})
|
|
2176
|
-
};
|
|
2177
|
-
} catch {
|
|
2178
|
-
return await forwardContentRpc(tab.id!, 'network.list', params);
|
|
2179
|
-
}
|
|
2180
|
-
});
|
|
2181
|
-
}
|
|
2182
|
-
case 'network.get': {
|
|
2183
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2184
|
-
const tab = await withTab(target);
|
|
2185
|
-
try {
|
|
2186
|
-
await ensureTabNetworkCapture(tab.id!);
|
|
2187
|
-
const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
|
|
2188
|
-
if (!entry) {
|
|
2189
|
-
throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
|
|
2190
|
-
}
|
|
2191
|
-
const filtered = filterNetworkEntrySections(
|
|
2192
|
-
truncateNetworkEntry(entry, typeof params.bodyBytes === 'number' ? params.bodyBytes : undefined),
|
|
2193
|
-
params.include
|
|
2194
|
-
);
|
|
2195
|
-
return { entry: filtered };
|
|
2196
|
-
} catch (error) {
|
|
2197
|
-
if ((error as { code?: string } | undefined)?.code === 'E_NOT_FOUND') {
|
|
2198
|
-
throw error;
|
|
2199
|
-
}
|
|
2200
|
-
return await forwardContentRpc(tab.id!, 'network.get', params);
|
|
2201
|
-
}
|
|
2202
|
-
});
|
|
2203
|
-
}
|
|
2204
|
-
case 'network.search': {
|
|
2205
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2206
|
-
const tab = await withTab(target);
|
|
2207
|
-
await ensureTabNetworkCapture(tab.id!);
|
|
2208
|
-
return {
|
|
2209
|
-
entries: searchNetworkEntries(
|
|
2210
|
-
tab.id!,
|
|
2211
|
-
String(params.pattern ?? ''),
|
|
2212
|
-
typeof params.limit === 'number' ? params.limit : 50
|
|
2213
|
-
)
|
|
2214
|
-
};
|
|
2215
|
-
});
|
|
2216
|
-
}
|
|
2217
|
-
case 'network.waitFor': {
|
|
2218
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2219
|
-
const tab = await withTab(target);
|
|
2220
|
-
try {
|
|
2221
|
-
await ensureTabNetworkCapture(tab.id!);
|
|
2222
|
-
} catch {
|
|
2223
|
-
return await forwardContentRpc(tab.id!, 'network.waitFor', params);
|
|
2224
|
-
}
|
|
2225
|
-
return {
|
|
2226
|
-
entry: await waitForNetworkEntry(tab.id!, {
|
|
2227
|
-
limit: 1,
|
|
2228
|
-
urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
|
|
2229
|
-
status: typeof params.status === 'number' ? params.status : undefined,
|
|
2230
|
-
method: typeof params.method === 'string' ? params.method : undefined,
|
|
2231
|
-
timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
|
|
2232
|
-
})
|
|
2233
|
-
};
|
|
2234
|
-
});
|
|
2235
|
-
}
|
|
2236
|
-
case 'network.clear': {
|
|
2237
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2238
|
-
const tab = await withTab(target);
|
|
2239
|
-
clearNetworkEntries(tab.id!);
|
|
2240
|
-
await forwardContentRpc(tab.id!, 'network.clear', params).catch(() => undefined);
|
|
2241
|
-
return { ok: true };
|
|
2242
|
-
});
|
|
2243
|
-
}
|
|
2244
|
-
case 'network.replay': {
|
|
2245
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2246
|
-
const tab = await withTab(target);
|
|
2247
|
-
await ensureTabNetworkCapture(tab.id!);
|
|
2248
|
-
const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
|
|
2249
|
-
if (!entry) {
|
|
2250
|
-
throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
|
|
2251
|
-
}
|
|
2252
|
-
if (entry.requestBodyTruncated === true) {
|
|
2253
|
-
throw toError('E_BODY_TOO_LARGE', 'captured request body was truncated and cannot be replayed safely', {
|
|
2254
|
-
requestId: entry.id,
|
|
2255
|
-
requestBytes: entry.requestBytes
|
|
2256
|
-
});
|
|
2257
|
-
}
|
|
2258
|
-
if (containsRedactionMarker(entry.requestBodyPreview)) {
|
|
2259
|
-
throw toError('E_EXECUTION', 'captured request body was redacted and cannot be replayed safely', {
|
|
2260
|
-
requestId: entry.id
|
|
2261
|
-
});
|
|
2262
|
-
}
|
|
2263
|
-
const replayed = await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', {
|
|
2264
|
-
url: entry.url,
|
|
2265
|
-
method: entry.method,
|
|
2266
|
-
headers: replayHeadersFromEntry(entry),
|
|
2267
|
-
body: entry.requestBodyPreview,
|
|
2268
|
-
contentType: (() => {
|
|
2269
|
-
const requestHeaders = entry.requestHeaders ?? {};
|
|
2270
|
-
const contentTypeHeader = Object.keys(requestHeaders).find((name) => name.toLowerCase() === 'content-type');
|
|
2271
|
-
return contentTypeHeader ? requestHeaders[contentTypeHeader] : undefined;
|
|
2272
|
-
})(),
|
|
2273
|
-
mode: params.mode,
|
|
2274
|
-
timeoutMs: params.timeoutMs,
|
|
2275
|
-
maxBytes: params.maxBytes,
|
|
2276
|
-
scope: 'current'
|
|
2277
|
-
});
|
|
2278
|
-
const frameResult = replayed.result ?? replayed.results?.find((candidate) => candidate.value || candidate.error);
|
|
2279
|
-
if (frameResult?.error) {
|
|
2280
|
-
throw toError(frameResult.error.code ?? 'E_EXECUTION', frameResult.error.message, frameResult.error.details);
|
|
2281
|
-
}
|
|
2282
|
-
const first = frameResult?.value;
|
|
2283
|
-
if (!first) {
|
|
2284
|
-
throw toError('E_EXECUTION', 'network replay returned no response payload');
|
|
2285
|
-
}
|
|
2286
|
-
return params.withSchema === 'auto' && params.mode === 'json' ? await enrichReplayWithSchema(tab.id!, first) : first;
|
|
2287
|
-
});
|
|
2288
|
-
}
|
|
2289
|
-
case 'page.freshness': {
|
|
2290
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2291
|
-
const tab = await withTab(target);
|
|
2292
|
-
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2293
|
-
return await buildFreshnessForTab(tab.id!, params);
|
|
2294
|
-
});
|
|
2295
|
-
}
|
|
2296
|
-
case 'debug.dumpState': {
|
|
2297
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2298
|
-
const tab = await withTab(target);
|
|
2299
|
-
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2300
|
-
const dump = (await forwardContentRpc(tab.id!, 'debug.dumpState', params)) as Record<string, unknown>;
|
|
2301
|
-
const inspection = await collectPageInspection(tab.id!, params);
|
|
2302
|
-
const network = listNetworkEntries(tab.id!, {
|
|
2303
|
-
limit: typeof params.networkLimit === 'number' ? params.networkLimit : 80
|
|
2304
|
-
});
|
|
2305
|
-
const sections = Array.isArray(params.section) ? new Set(params.section.map(String) as DebugDumpSection[]) : null;
|
|
2306
|
-
const result: Record<string, unknown> = {
|
|
2307
|
-
...dump,
|
|
2308
|
-
network,
|
|
2309
|
-
scripts: inspection.scripts,
|
|
2310
|
-
globalsPreview: inspection.globalsPreview,
|
|
2311
|
-
storage: inspection.storage,
|
|
2312
|
-
frames: inspection.frames,
|
|
2313
|
-
networkSummary: {
|
|
2314
|
-
total: network.length,
|
|
2315
|
-
recent: network.slice(0, Math.min(10, network.length))
|
|
2316
|
-
}
|
|
2317
|
-
};
|
|
2318
|
-
if (!sections || sections.size === 0) {
|
|
2319
|
-
return result;
|
|
2320
|
-
}
|
|
2321
|
-
const filtered: Record<string, unknown> = {
|
|
2322
|
-
url: result.url,
|
|
2323
|
-
title: result.title,
|
|
2324
|
-
context: result.context
|
|
2325
|
-
};
|
|
2326
|
-
if (sections.has('dom')) {
|
|
2327
|
-
filtered.dom = result.dom;
|
|
2328
|
-
}
|
|
2329
|
-
if (sections.has('visible-text')) {
|
|
2330
|
-
filtered.text = result.text;
|
|
2331
|
-
filtered.elements = result.elements;
|
|
2332
|
-
}
|
|
2333
|
-
if (sections.has('scripts')) {
|
|
2334
|
-
filtered.scripts = result.scripts;
|
|
2335
|
-
}
|
|
2336
|
-
if (sections.has('globals-preview')) {
|
|
2337
|
-
filtered.globalsPreview = result.globalsPreview;
|
|
2338
|
-
}
|
|
2339
|
-
if (sections.has('network-summary')) {
|
|
2340
|
-
filtered.networkSummary = result.networkSummary;
|
|
2341
|
-
}
|
|
2342
|
-
if (sections.has('storage')) {
|
|
2343
|
-
filtered.storage = result.storage;
|
|
2344
|
-
}
|
|
2345
|
-
if (sections.has('frames')) {
|
|
2346
|
-
filtered.frames = result.frames;
|
|
2347
|
-
}
|
|
2348
|
-
if (params.includeAccessibility === true && 'accessibility' in result) {
|
|
2349
|
-
filtered.accessibility = result.accessibility;
|
|
2350
|
-
}
|
|
2351
|
-
if ('snapshot' in result) {
|
|
2352
|
-
filtered.snapshot = result.snapshot;
|
|
2353
|
-
}
|
|
2354
|
-
return filtered;
|
|
2355
|
-
});
|
|
2356
|
-
}
|
|
2357
|
-
case 'inspect.pageData': {
|
|
2358
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2359
|
-
const tab = await withTab(target);
|
|
2360
|
-
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2361
|
-
const inspection = await collectPageInspection(tab.id!, params);
|
|
2362
|
-
const pageDataCandidates = await probePageDataCandidatesForTab(tab.id!, inspection);
|
|
2363
|
-
const network = listNetworkEntries(tab.id!, { limit: 10 });
|
|
2364
|
-
return {
|
|
2365
|
-
suspiciousGlobals: inspection.suspiciousGlobals ?? [],
|
|
2366
|
-
tables: inspection.tables ?? [],
|
|
2367
|
-
visibleTimestamps: inspection.visibleTimestamps ?? [],
|
|
2368
|
-
inlineTimestamps: inspection.inlineTimestamps ?? [],
|
|
2369
|
-
pageDataCandidates,
|
|
2370
|
-
recentNetwork: network,
|
|
2371
|
-
recommendedNextSteps: [
|
|
2372
|
-
'bak page extract --path table_data --resolver auto',
|
|
2373
|
-
'bak network search --pattern table_data',
|
|
2374
|
-
'bak page freshness'
|
|
2375
|
-
]
|
|
2376
|
-
};
|
|
2377
|
-
});
|
|
2378
|
-
}
|
|
2379
|
-
case 'inspect.liveUpdates': {
|
|
2380
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2381
|
-
const tab = await withTab(target);
|
|
2382
|
-
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2383
|
-
const inspection = await collectPageInspection(tab.id!, params);
|
|
2384
|
-
const network = listNetworkEntries(tab.id!, { limit: 25 });
|
|
2385
|
-
return {
|
|
2386
|
-
lastMutationAt: inspection.lastMutationAt ?? null,
|
|
2387
|
-
timers: inspection.timers ?? { timeouts: 0, intervals: 0 },
|
|
2388
|
-
networkCount: network.length,
|
|
2389
|
-
networkCadence: summarizeNetworkCadence(network),
|
|
2390
|
-
recentNetwork: network.slice(0, 10)
|
|
2391
|
-
};
|
|
2392
|
-
});
|
|
2393
|
-
}
|
|
2394
|
-
case 'inspect.freshness': {
|
|
2395
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2396
|
-
const tab = await withTab(target);
|
|
2397
|
-
const freshness = await buildFreshnessForTab(tab.id!, params);
|
|
2398
|
-
return {
|
|
2399
|
-
...freshness,
|
|
2400
|
-
lagMs:
|
|
2401
|
-
typeof freshness.latestNetworkTimestamp === 'number' &&
|
|
2402
|
-
typeof (freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp) === 'number'
|
|
2403
|
-
? Math.max(
|
|
2404
|
-
0,
|
|
2405
|
-
freshness.latestNetworkTimestamp -
|
|
2406
|
-
(freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp ?? freshness.latestNetworkTimestamp)
|
|
2407
|
-
)
|
|
2408
|
-
: null
|
|
2409
|
-
};
|
|
2410
|
-
});
|
|
2411
|
-
}
|
|
2412
|
-
case 'capture.snapshot': {
|
|
2413
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2414
|
-
const tab = await withTab(target);
|
|
2415
|
-
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2416
|
-
const inspection = await collectPageInspection(tab.id!, params);
|
|
2417
|
-
return {
|
|
2418
|
-
url: inspection.url ?? tab.url ?? '',
|
|
2419
|
-
title: inspection.title ?? tab.title ?? '',
|
|
2420
|
-
html: inspection.html ?? '',
|
|
2421
|
-
visibleText: inspection.visibleText ?? [],
|
|
2422
|
-
cookies: inspection.cookies ?? [],
|
|
2423
|
-
storage: inspection.storage ?? { localStorageKeys: [], sessionStorageKeys: [] },
|
|
2424
|
-
context: inspection.context ?? { tabId: tab.id, framePath: [], shadowPath: [] },
|
|
2425
|
-
freshness: await buildFreshnessForTab(tab.id!, params),
|
|
2426
|
-
network: listNetworkEntries(tab.id!, {
|
|
2427
|
-
limit: typeof params.networkLimit === 'number' ? params.networkLimit : 20
|
|
2428
|
-
}),
|
|
2429
|
-
capturedAt: Date.now()
|
|
2430
|
-
};
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
case 'capture.har': {
|
|
2434
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2435
|
-
const tab = await withTab(target);
|
|
2436
|
-
await ensureTabNetworkCapture(tab.id!);
|
|
2437
|
-
return {
|
|
2438
|
-
har: exportHar(tab.id!, typeof params.limit === 'number' ? params.limit : undefined)
|
|
2439
|
-
};
|
|
2440
|
-
});
|
|
2441
|
-
}
|
|
2442
|
-
case 'ui.selectCandidate': {
|
|
2443
|
-
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2444
|
-
const tab = await withTab(target);
|
|
2112
|
+
case 'debug.getConsole': {
|
|
2113
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2114
|
+
const tab = await withTab(target);
|
|
2115
|
+
const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
|
|
2116
|
+
type: 'bak.getConsole',
|
|
2117
|
+
limit: Number(params.limit ?? 50)
|
|
2118
|
+
});
|
|
2119
|
+
return { entries: response.entries };
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
case 'network.list': {
|
|
2123
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2124
|
+
const tab = await withTab(target);
|
|
2125
|
+
try {
|
|
2126
|
+
await ensureTabNetworkCapture(tab.id!);
|
|
2127
|
+
return {
|
|
2128
|
+
entries: listNetworkEntries(tab.id!, {
|
|
2129
|
+
limit: typeof params.limit === 'number' ? params.limit : undefined,
|
|
2130
|
+
urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
|
|
2131
|
+
status: typeof params.status === 'number' ? params.status : undefined,
|
|
2132
|
+
method: typeof params.method === 'string' ? params.method : undefined
|
|
2133
|
+
})
|
|
2134
|
+
};
|
|
2135
|
+
} catch {
|
|
2136
|
+
return await forwardContentRpc(tab.id!, 'network.list', params);
|
|
2137
|
+
}
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
case 'network.get': {
|
|
2141
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2142
|
+
const tab = await withTab(target);
|
|
2143
|
+
try {
|
|
2144
|
+
await ensureTabNetworkCapture(tab.id!);
|
|
2145
|
+
const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
|
|
2146
|
+
if (!entry) {
|
|
2147
|
+
throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
|
|
2148
|
+
}
|
|
2149
|
+
const filtered = filterNetworkEntrySections(
|
|
2150
|
+
truncateNetworkEntry(entry, typeof params.bodyBytes === 'number' ? params.bodyBytes : undefined),
|
|
2151
|
+
params.include
|
|
2152
|
+
);
|
|
2153
|
+
return { entry: filtered };
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
if ((error as { code?: string } | undefined)?.code === 'E_NOT_FOUND') {
|
|
2156
|
+
throw error;
|
|
2157
|
+
}
|
|
2158
|
+
return await forwardContentRpc(tab.id!, 'network.get', params);
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
case 'network.search': {
|
|
2163
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2164
|
+
const tab = await withTab(target);
|
|
2165
|
+
await ensureTabNetworkCapture(tab.id!);
|
|
2166
|
+
return {
|
|
2167
|
+
entries: searchNetworkEntries(
|
|
2168
|
+
tab.id!,
|
|
2169
|
+
String(params.pattern ?? ''),
|
|
2170
|
+
typeof params.limit === 'number' ? params.limit : 50
|
|
2171
|
+
)
|
|
2172
|
+
};
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
case 'network.waitFor': {
|
|
2176
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2177
|
+
const tab = await withTab(target);
|
|
2178
|
+
try {
|
|
2179
|
+
await ensureTabNetworkCapture(tab.id!);
|
|
2180
|
+
} catch {
|
|
2181
|
+
return await forwardContentRpc(tab.id!, 'network.waitFor', params);
|
|
2182
|
+
}
|
|
2183
|
+
return {
|
|
2184
|
+
entry: await waitForNetworkEntry(tab.id!, {
|
|
2185
|
+
limit: 1,
|
|
2186
|
+
urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
|
|
2187
|
+
status: typeof params.status === 'number' ? params.status : undefined,
|
|
2188
|
+
method: typeof params.method === 'string' ? params.method : undefined,
|
|
2189
|
+
timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined
|
|
2190
|
+
})
|
|
2191
|
+
};
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
case 'network.clear': {
|
|
2195
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2196
|
+
const tab = await withTab(target);
|
|
2197
|
+
clearNetworkEntries(tab.id!);
|
|
2198
|
+
await forwardContentRpc(tab.id!, 'network.clear', params).catch(() => undefined);
|
|
2199
|
+
return { ok: true };
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
case 'network.replay': {
|
|
2203
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2204
|
+
const tab = await withTab(target);
|
|
2205
|
+
await ensureTabNetworkCapture(tab.id!);
|
|
2206
|
+
const entry = getNetworkEntry(tab.id!, String(params.id ?? ''));
|
|
2207
|
+
if (!entry) {
|
|
2208
|
+
throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
|
|
2209
|
+
}
|
|
2210
|
+
if (entry.requestBodyTruncated === true) {
|
|
2211
|
+
throw toError('E_BODY_TOO_LARGE', 'captured request body was truncated and cannot be replayed safely', {
|
|
2212
|
+
requestId: entry.id,
|
|
2213
|
+
requestBytes: entry.requestBytes
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
if (containsRedactionMarker(entry.requestBodyPreview)) {
|
|
2217
|
+
throw toError('E_EXECUTION', 'captured request body was redacted and cannot be replayed safely', {
|
|
2218
|
+
requestId: entry.id
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
const replayed = await executePageWorld<PageFetchResponse>(tab.id!, 'fetch', {
|
|
2222
|
+
url: entry.url,
|
|
2223
|
+
method: entry.method,
|
|
2224
|
+
headers: replayHeadersFromEntry(entry),
|
|
2225
|
+
body: entry.requestBodyPreview,
|
|
2226
|
+
contentType: (() => {
|
|
2227
|
+
const requestHeaders = entry.requestHeaders ?? {};
|
|
2228
|
+
const contentTypeHeader = Object.keys(requestHeaders).find((name) => name.toLowerCase() === 'content-type');
|
|
2229
|
+
return contentTypeHeader ? requestHeaders[contentTypeHeader] : undefined;
|
|
2230
|
+
})(),
|
|
2231
|
+
mode: params.mode,
|
|
2232
|
+
timeoutMs: params.timeoutMs,
|
|
2233
|
+
maxBytes: params.maxBytes,
|
|
2234
|
+
scope: 'current'
|
|
2235
|
+
});
|
|
2236
|
+
const frameResult = replayed.result ?? replayed.results?.find((candidate) => candidate.value || candidate.error);
|
|
2237
|
+
if (frameResult?.error) {
|
|
2238
|
+
throw toError(frameResult.error.code ?? 'E_EXECUTION', frameResult.error.message, frameResult.error.details);
|
|
2239
|
+
}
|
|
2240
|
+
const first = frameResult?.value;
|
|
2241
|
+
if (!first) {
|
|
2242
|
+
throw toError('E_EXECUTION', 'network replay returned no response payload');
|
|
2243
|
+
}
|
|
2244
|
+
return params.withSchema === 'auto' && params.mode === 'json' ? await enrichReplayWithSchema(tab.id!, first) : first;
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
case 'page.freshness': {
|
|
2248
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2249
|
+
const tab = await withTab(target);
|
|
2250
|
+
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2251
|
+
return await buildFreshnessForTab(tab.id!, params);
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
case 'debug.dumpState': {
|
|
2255
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2256
|
+
const tab = await withTab(target);
|
|
2257
|
+
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2258
|
+
const dump = (await forwardContentRpc(tab.id!, 'debug.dumpState', params)) as Record<string, unknown>;
|
|
2259
|
+
const inspection = await collectPageInspection(tab.id!, params);
|
|
2260
|
+
const network = listNetworkEntries(tab.id!, {
|
|
2261
|
+
limit: typeof params.networkLimit === 'number' ? params.networkLimit : 80
|
|
2262
|
+
});
|
|
2263
|
+
const sections = Array.isArray(params.section) ? new Set(params.section.map(String) as DebugDumpSection[]) : null;
|
|
2264
|
+
const result: Record<string, unknown> = {
|
|
2265
|
+
...dump,
|
|
2266
|
+
network,
|
|
2267
|
+
scripts: inspection.scripts,
|
|
2268
|
+
globalsPreview: inspection.globalsPreview,
|
|
2269
|
+
storage: inspection.storage,
|
|
2270
|
+
frames: inspection.frames,
|
|
2271
|
+
networkSummary: {
|
|
2272
|
+
total: network.length,
|
|
2273
|
+
recent: network.slice(0, Math.min(10, network.length))
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
if (!sections || sections.size === 0) {
|
|
2277
|
+
return result;
|
|
2278
|
+
}
|
|
2279
|
+
const filtered: Record<string, unknown> = {
|
|
2280
|
+
url: result.url,
|
|
2281
|
+
title: result.title,
|
|
2282
|
+
context: result.context
|
|
2283
|
+
};
|
|
2284
|
+
if (sections.has('dom')) {
|
|
2285
|
+
filtered.dom = result.dom;
|
|
2286
|
+
}
|
|
2287
|
+
if (sections.has('visible-text')) {
|
|
2288
|
+
filtered.text = result.text;
|
|
2289
|
+
filtered.elements = result.elements;
|
|
2290
|
+
}
|
|
2291
|
+
if (sections.has('scripts')) {
|
|
2292
|
+
filtered.scripts = result.scripts;
|
|
2293
|
+
}
|
|
2294
|
+
if (sections.has('globals-preview')) {
|
|
2295
|
+
filtered.globalsPreview = result.globalsPreview;
|
|
2296
|
+
}
|
|
2297
|
+
if (sections.has('network-summary')) {
|
|
2298
|
+
filtered.networkSummary = result.networkSummary;
|
|
2299
|
+
}
|
|
2300
|
+
if (sections.has('storage')) {
|
|
2301
|
+
filtered.storage = result.storage;
|
|
2302
|
+
}
|
|
2303
|
+
if (sections.has('frames')) {
|
|
2304
|
+
filtered.frames = result.frames;
|
|
2305
|
+
}
|
|
2306
|
+
if (params.includeAccessibility === true && 'accessibility' in result) {
|
|
2307
|
+
filtered.accessibility = result.accessibility;
|
|
2308
|
+
}
|
|
2309
|
+
if ('snapshot' in result) {
|
|
2310
|
+
filtered.snapshot = result.snapshot;
|
|
2311
|
+
}
|
|
2312
|
+
return filtered;
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
case 'inspect.pageData': {
|
|
2316
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2317
|
+
const tab = await withTab(target);
|
|
2318
|
+
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2319
|
+
const inspection = await collectPageInspection(tab.id!, params);
|
|
2320
|
+
const pageDataCandidates = await probePageDataCandidatesForTab(tab.id!, inspection);
|
|
2321
|
+
const network = listNetworkEntries(tab.id!, { limit: 10 });
|
|
2322
|
+
return {
|
|
2323
|
+
suspiciousGlobals: inspection.suspiciousGlobals ?? [],
|
|
2324
|
+
tables: inspection.tables ?? [],
|
|
2325
|
+
visibleTimestamps: inspection.visibleTimestamps ?? [],
|
|
2326
|
+
inlineTimestamps: inspection.inlineTimestamps ?? [],
|
|
2327
|
+
pageDataCandidates,
|
|
2328
|
+
recentNetwork: network,
|
|
2329
|
+
recommendedNextSteps: [
|
|
2330
|
+
'bak page extract --path table_data --resolver auto',
|
|
2331
|
+
'bak network search --pattern table_data',
|
|
2332
|
+
'bak page freshness'
|
|
2333
|
+
]
|
|
2334
|
+
};
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
case 'inspect.liveUpdates': {
|
|
2338
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2339
|
+
const tab = await withTab(target);
|
|
2340
|
+
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2341
|
+
const inspection = await collectPageInspection(tab.id!, params);
|
|
2342
|
+
const network = listNetworkEntries(tab.id!, { limit: 25 });
|
|
2343
|
+
return {
|
|
2344
|
+
lastMutationAt: inspection.lastMutationAt ?? null,
|
|
2345
|
+
timers: inspection.timers ?? { timeouts: 0, intervals: 0 },
|
|
2346
|
+
networkCount: network.length,
|
|
2347
|
+
networkCadence: summarizeNetworkCadence(network),
|
|
2348
|
+
recentNetwork: network.slice(0, 10)
|
|
2349
|
+
};
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
case 'inspect.freshness': {
|
|
2353
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2354
|
+
const tab = await withTab(target);
|
|
2355
|
+
const freshness = await buildFreshnessForTab(tab.id!, params);
|
|
2356
|
+
return {
|
|
2357
|
+
...freshness,
|
|
2358
|
+
lagMs:
|
|
2359
|
+
typeof freshness.latestNetworkTimestamp === 'number' &&
|
|
2360
|
+
typeof (freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp) === 'number'
|
|
2361
|
+
? Math.max(
|
|
2362
|
+
0,
|
|
2363
|
+
freshness.latestNetworkTimestamp -
|
|
2364
|
+
(freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp ?? freshness.latestNetworkTimestamp)
|
|
2365
|
+
)
|
|
2366
|
+
: null
|
|
2367
|
+
};
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
case 'capture.snapshot': {
|
|
2371
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2372
|
+
const tab = await withTab(target);
|
|
2373
|
+
await ensureNetworkDebugger(tab.id!).catch(() => undefined);
|
|
2374
|
+
const inspection = await collectPageInspection(tab.id!, params);
|
|
2375
|
+
return {
|
|
2376
|
+
url: inspection.url ?? tab.url ?? '',
|
|
2377
|
+
title: inspection.title ?? tab.title ?? '',
|
|
2378
|
+
html: inspection.html ?? '',
|
|
2379
|
+
visibleText: inspection.visibleText ?? [],
|
|
2380
|
+
cookies: inspection.cookies ?? [],
|
|
2381
|
+
storage: inspection.storage ?? { localStorageKeys: [], sessionStorageKeys: [] },
|
|
2382
|
+
context: inspection.context ?? { tabId: tab.id, framePath: [], shadowPath: [] },
|
|
2383
|
+
freshness: await buildFreshnessForTab(tab.id!, params),
|
|
2384
|
+
network: listNetworkEntries(tab.id!, {
|
|
2385
|
+
limit: typeof params.networkLimit === 'number' ? params.networkLimit : 20
|
|
2386
|
+
}),
|
|
2387
|
+
capturedAt: Date.now()
|
|
2388
|
+
};
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
case 'capture.har': {
|
|
2392
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2393
|
+
const tab = await withTab(target);
|
|
2394
|
+
await ensureTabNetworkCapture(tab.id!);
|
|
2395
|
+
return {
|
|
2396
|
+
har: exportHar(tab.id!, typeof params.limit === 'number' ? params.limit : undefined)
|
|
2397
|
+
};
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
case 'ui.selectCandidate': {
|
|
2401
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
2402
|
+
const tab = await withTab(target);
|
|
2445
2403
|
const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
|
|
2446
2404
|
tab.id!,
|
|
2447
2405
|
{
|
|
@@ -2477,16 +2435,16 @@ function scheduleReconnect(reason: string): void {
|
|
|
2477
2435
|
return;
|
|
2478
2436
|
}
|
|
2479
2437
|
|
|
2480
|
-
const delayMs = computeReconnectDelayMs(reconnectAttempt);
|
|
2481
|
-
reconnectAttempt += 1;
|
|
2482
|
-
nextReconnectInMs = delayMs;
|
|
2483
|
-
nextReconnectAt = Date.now() + delayMs;
|
|
2484
|
-
reconnectTimer = setTimeout(() => {
|
|
2485
|
-
reconnectTimer = null;
|
|
2486
|
-
nextReconnectInMs = null;
|
|
2487
|
-
nextReconnectAt = null;
|
|
2488
|
-
void connectWebSocket();
|
|
2489
|
-
}, delayMs) as unknown as number;
|
|
2438
|
+
const delayMs = computeReconnectDelayMs(reconnectAttempt);
|
|
2439
|
+
reconnectAttempt += 1;
|
|
2440
|
+
nextReconnectInMs = delayMs;
|
|
2441
|
+
nextReconnectAt = Date.now() + delayMs;
|
|
2442
|
+
reconnectTimer = setTimeout(() => {
|
|
2443
|
+
reconnectTimer = null;
|
|
2444
|
+
nextReconnectInMs = null;
|
|
2445
|
+
nextReconnectAt = null;
|
|
2446
|
+
void connectWebSocket();
|
|
2447
|
+
}, delayMs) as unknown as number;
|
|
2490
2448
|
|
|
2491
2449
|
if (!lastError) {
|
|
2492
2450
|
setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
|
|
@@ -2509,30 +2467,30 @@ async function connectWebSocket(): Promise<void> {
|
|
|
2509
2467
|
return;
|
|
2510
2468
|
}
|
|
2511
2469
|
|
|
2512
|
-
const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
|
|
2513
|
-
const socket = new WebSocket(url);
|
|
2514
|
-
ws = socket;
|
|
2515
|
-
|
|
2516
|
-
socket.addEventListener('open', () => {
|
|
2517
|
-
if (ws !== socket) {
|
|
2518
|
-
return;
|
|
2519
|
-
}
|
|
2520
|
-
manualDisconnect = false;
|
|
2521
|
-
reconnectAttempt = 0;
|
|
2522
|
-
lastError = null;
|
|
2523
|
-
socket.send(JSON.stringify({
|
|
2524
|
-
type: 'hello',
|
|
2525
|
-
role: 'extension',
|
|
2526
|
-
version: EXTENSION_VERSION,
|
|
2527
|
-
ts: Date.now()
|
|
2528
|
-
}));
|
|
2529
|
-
});
|
|
2530
|
-
|
|
2531
|
-
socket.addEventListener('message', (event) => {
|
|
2532
|
-
try {
|
|
2533
|
-
const request = JSON.parse(String(event.data)) as CliRequest;
|
|
2534
|
-
if (!request.id || !request.method) {
|
|
2535
|
-
return;
|
|
2470
|
+
const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
|
|
2471
|
+
const socket = new WebSocket(url);
|
|
2472
|
+
ws = socket;
|
|
2473
|
+
|
|
2474
|
+
socket.addEventListener('open', () => {
|
|
2475
|
+
if (ws !== socket) {
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
manualDisconnect = false;
|
|
2479
|
+
reconnectAttempt = 0;
|
|
2480
|
+
lastError = null;
|
|
2481
|
+
socket.send(JSON.stringify({
|
|
2482
|
+
type: 'hello',
|
|
2483
|
+
role: 'extension',
|
|
2484
|
+
version: EXTENSION_VERSION,
|
|
2485
|
+
ts: Date.now()
|
|
2486
|
+
}));
|
|
2487
|
+
});
|
|
2488
|
+
|
|
2489
|
+
socket.addEventListener('message', (event) => {
|
|
2490
|
+
try {
|
|
2491
|
+
const request = JSON.parse(String(event.data)) as CliRequest;
|
|
2492
|
+
if (!request.id || !request.method) {
|
|
2493
|
+
return;
|
|
2536
2494
|
}
|
|
2537
2495
|
void handleRequest(request)
|
|
2538
2496
|
.then((result) => {
|
|
@@ -2549,112 +2507,112 @@ async function connectWebSocket(): Promise<void> {
|
|
|
2549
2507
|
ok: false,
|
|
2550
2508
|
error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
|
|
2551
2509
|
});
|
|
2552
|
-
}
|
|
2553
|
-
});
|
|
2554
|
-
|
|
2555
|
-
socket.addEventListener('close', () => {
|
|
2556
|
-
if (ws !== socket) {
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
2559
|
-
ws = null;
|
|
2560
|
-
scheduleReconnect('socket-closed');
|
|
2561
|
-
});
|
|
2562
|
-
|
|
2563
|
-
socket.addEventListener('error', () => {
|
|
2564
|
-
if (ws !== socket) {
|
|
2565
|
-
return;
|
|
2566
|
-
}
|
|
2567
|
-
setRuntimeError('Cannot connect to bak cli', 'socket');
|
|
2568
|
-
socket.close();
|
|
2569
|
-
});
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
2573
|
-
dropNetworkCapture(tabId);
|
|
2574
|
-
void mutateSessionBindingStateMap((stateMap) => {
|
|
2575
|
-
const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
|
|
2576
|
-
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2577
|
-
if (!state.tabIds.includes(tabId)) {
|
|
2578
|
-
continue;
|
|
2579
|
-
}
|
|
2580
|
-
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
2581
|
-
if (nextTabIds.length === 0) {
|
|
2582
|
-
delete stateMap[bindingId];
|
|
2583
|
-
updates.push({ bindingId, state: null });
|
|
2584
|
-
continue;
|
|
2585
|
-
}
|
|
2586
|
-
const fallbackTabId = nextTabIds[0] ?? null;
|
|
2587
|
-
const nextState: SessionBindingRecord = {
|
|
2588
|
-
...state,
|
|
2589
|
-
tabIds: nextTabIds,
|
|
2590
|
-
activeTabId: state.activeTabId === tabId ? fallbackTabId : state.activeTabId,
|
|
2591
|
-
primaryTabId: state.primaryTabId === tabId ? fallbackTabId : state.primaryTabId
|
|
2592
|
-
};
|
|
2593
|
-
stateMap[bindingId] = nextState;
|
|
2594
|
-
updates.push({ bindingId, state: nextState });
|
|
2595
|
-
}
|
|
2596
|
-
return updates;
|
|
2597
|
-
}).then((updates) => {
|
|
2598
|
-
for (const update of updates) {
|
|
2599
|
-
emitSessionBindingUpdated(update.bindingId, 'tab-removed', update.state, {
|
|
2600
|
-
closedTabId: tabId
|
|
2601
|
-
});
|
|
2602
|
-
}
|
|
2603
|
-
});
|
|
2604
|
-
});
|
|
2605
|
-
|
|
2606
|
-
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
2607
|
-
if (preserveHumanFocusDepth > 0) {
|
|
2608
|
-
return;
|
|
2609
|
-
}
|
|
2610
|
-
void chrome.windows
|
|
2611
|
-
.get(activeInfo.windowId)
|
|
2612
|
-
.then((window) => window.focused === true)
|
|
2613
|
-
.catch(() => false)
|
|
2614
|
-
.then((windowFocused) => {
|
|
2615
|
-
if (!windowFocused) {
|
|
2616
|
-
return [] as Array<{ bindingId: string; state: SessionBindingRecord }>;
|
|
2617
|
-
}
|
|
2618
|
-
return mutateSessionBindingStateMap((stateMap) => {
|
|
2619
|
-
const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
|
|
2620
|
-
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2621
|
-
if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
|
|
2622
|
-
continue;
|
|
2623
|
-
}
|
|
2624
|
-
const nextState: SessionBindingRecord = {
|
|
2625
|
-
...state,
|
|
2626
|
-
activeTabId: activeInfo.tabId
|
|
2627
|
-
};
|
|
2628
|
-
stateMap[bindingId] = nextState;
|
|
2629
|
-
updates.push({ bindingId, state: nextState });
|
|
2630
|
-
}
|
|
2631
|
-
return updates;
|
|
2632
|
-
});
|
|
2633
|
-
})
|
|
2634
|
-
.then((updates) => {
|
|
2635
|
-
for (const update of updates) {
|
|
2636
|
-
emitSessionBindingUpdated(update.bindingId, 'tab-activated', update.state);
|
|
2637
|
-
}
|
|
2638
|
-
});
|
|
2639
|
-
});
|
|
2640
|
-
|
|
2641
|
-
chrome.windows.onRemoved.addListener((windowId) => {
|
|
2642
|
-
void mutateSessionBindingStateMap((stateMap) => {
|
|
2643
|
-
const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
|
|
2644
|
-
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2645
|
-
if (state.windowId !== windowId) {
|
|
2646
|
-
continue;
|
|
2647
|
-
}
|
|
2648
|
-
delete stateMap[bindingId];
|
|
2649
|
-
updates.push({ bindingId, state: null });
|
|
2650
|
-
}
|
|
2651
|
-
return updates;
|
|
2652
|
-
}).then((updates) => {
|
|
2653
|
-
for (const update of updates) {
|
|
2654
|
-
emitSessionBindingUpdated(update.bindingId, 'window-removed', update.state);
|
|
2655
|
-
}
|
|
2656
|
-
});
|
|
2657
|
-
});
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
socket.addEventListener('close', () => {
|
|
2514
|
+
if (ws !== socket) {
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
ws = null;
|
|
2518
|
+
scheduleReconnect('socket-closed');
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
socket.addEventListener('error', () => {
|
|
2522
|
+
if (ws !== socket) {
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
setRuntimeError('Cannot connect to bak cli', 'socket');
|
|
2526
|
+
socket.close();
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
2531
|
+
dropNetworkCapture(tabId);
|
|
2532
|
+
void mutateSessionBindingStateMap((stateMap) => {
|
|
2533
|
+
const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
|
|
2534
|
+
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2535
|
+
if (!state.tabIds.includes(tabId)) {
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
2539
|
+
if (nextTabIds.length === 0) {
|
|
2540
|
+
delete stateMap[bindingId];
|
|
2541
|
+
updates.push({ bindingId, state: null });
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
const fallbackTabId = nextTabIds[0] ?? null;
|
|
2545
|
+
const nextState: SessionBindingRecord = {
|
|
2546
|
+
...state,
|
|
2547
|
+
tabIds: nextTabIds,
|
|
2548
|
+
activeTabId: state.activeTabId === tabId ? fallbackTabId : state.activeTabId,
|
|
2549
|
+
primaryTabId: state.primaryTabId === tabId ? fallbackTabId : state.primaryTabId
|
|
2550
|
+
};
|
|
2551
|
+
stateMap[bindingId] = nextState;
|
|
2552
|
+
updates.push({ bindingId, state: nextState });
|
|
2553
|
+
}
|
|
2554
|
+
return updates;
|
|
2555
|
+
}).then((updates) => {
|
|
2556
|
+
for (const update of updates) {
|
|
2557
|
+
emitSessionBindingUpdated(update.bindingId, 'tab-removed', update.state, {
|
|
2558
|
+
closedTabId: tabId
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
2565
|
+
if (preserveHumanFocusDepth > 0) {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
void chrome.windows
|
|
2569
|
+
.get(activeInfo.windowId)
|
|
2570
|
+
.then((window) => window.focused === true)
|
|
2571
|
+
.catch(() => false)
|
|
2572
|
+
.then((windowFocused) => {
|
|
2573
|
+
if (!windowFocused) {
|
|
2574
|
+
return [] as Array<{ bindingId: string; state: SessionBindingRecord }>;
|
|
2575
|
+
}
|
|
2576
|
+
return mutateSessionBindingStateMap((stateMap) => {
|
|
2577
|
+
const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
|
|
2578
|
+
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2579
|
+
if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
const nextState: SessionBindingRecord = {
|
|
2583
|
+
...state,
|
|
2584
|
+
activeTabId: activeInfo.tabId
|
|
2585
|
+
};
|
|
2586
|
+
stateMap[bindingId] = nextState;
|
|
2587
|
+
updates.push({ bindingId, state: nextState });
|
|
2588
|
+
}
|
|
2589
|
+
return updates;
|
|
2590
|
+
});
|
|
2591
|
+
})
|
|
2592
|
+
.then((updates) => {
|
|
2593
|
+
for (const update of updates) {
|
|
2594
|
+
emitSessionBindingUpdated(update.bindingId, 'tab-activated', update.state);
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
chrome.windows.onRemoved.addListener((windowId) => {
|
|
2600
|
+
void mutateSessionBindingStateMap((stateMap) => {
|
|
2601
|
+
const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
|
|
2602
|
+
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2603
|
+
if (state.windowId !== windowId) {
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
delete stateMap[bindingId];
|
|
2607
|
+
updates.push({ bindingId, state: null });
|
|
2608
|
+
}
|
|
2609
|
+
return updates;
|
|
2610
|
+
}).then((updates) => {
|
|
2611
|
+
for (const update of updates) {
|
|
2612
|
+
emitSessionBindingUpdated(update.bindingId, 'window-removed', update.state);
|
|
2613
|
+
}
|
|
2614
|
+
});
|
|
2615
|
+
});
|
|
2658
2616
|
|
|
2659
2617
|
chrome.runtime.onInstalled.addListener(() => {
|
|
2660
2618
|
void setConfig({ port: DEFAULT_PORT, debugRichText: false });
|
|
@@ -2667,49 +2625,49 @@ chrome.runtime.onStartup.addListener(() => {
|
|
|
2667
2625
|
void connectWebSocket();
|
|
2668
2626
|
|
|
2669
2627
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
2670
|
-
if (message?.type === 'bak.updateConfig') {
|
|
2671
|
-
manualDisconnect = false;
|
|
2672
|
-
const token = typeof message.token === 'string' ? message.token.trim() : '';
|
|
2673
|
-
void setConfig({
|
|
2674
|
-
...(token ? { token } : {}),
|
|
2675
|
-
port: Number(message.port ?? DEFAULT_PORT),
|
|
2676
|
-
debugRichText: message.debugRichText === true
|
|
2677
|
-
}).then(() => {
|
|
2678
|
-
ws?.close();
|
|
2679
|
-
void connectWebSocket().then(() => sendResponse({ ok: true }));
|
|
2680
|
-
});
|
|
2681
|
-
return true;
|
|
2682
|
-
}
|
|
2628
|
+
if (message?.type === 'bak.updateConfig') {
|
|
2629
|
+
manualDisconnect = false;
|
|
2630
|
+
const token = typeof message.token === 'string' ? message.token.trim() : '';
|
|
2631
|
+
void setConfig({
|
|
2632
|
+
...(token ? { token } : {}),
|
|
2633
|
+
port: Number(message.port ?? DEFAULT_PORT),
|
|
2634
|
+
debugRichText: message.debugRichText === true
|
|
2635
|
+
}).then(() => {
|
|
2636
|
+
ws?.close();
|
|
2637
|
+
void connectWebSocket().then(() => sendResponse({ ok: true }));
|
|
2638
|
+
});
|
|
2639
|
+
return true;
|
|
2640
|
+
}
|
|
2683
2641
|
|
|
2684
|
-
if (message?.type === 'bak.getState') {
|
|
2685
|
-
void buildPopupState().then((state) => {
|
|
2686
|
-
sendResponse(state);
|
|
2687
|
-
});
|
|
2688
|
-
return true;
|
|
2689
|
-
}
|
|
2690
|
-
|
|
2691
|
-
if (message?.type === 'bak.disconnect') {
|
|
2692
|
-
manualDisconnect = true;
|
|
2693
|
-
clearReconnectTimer();
|
|
2694
|
-
reconnectAttempt = 0;
|
|
2695
|
-
lastError = null;
|
|
2696
|
-
ws?.close();
|
|
2697
|
-
ws = null;
|
|
2698
|
-
sendResponse({ ok: true });
|
|
2699
|
-
return false;
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
if (message?.type === 'bak.reconnectNow') {
|
|
2703
|
-
manualDisconnect = false;
|
|
2704
|
-
clearReconnectTimer();
|
|
2705
|
-
reconnectAttempt = 0;
|
|
2706
|
-
ws?.close();
|
|
2707
|
-
ws = null;
|
|
2708
|
-
void connectWebSocket().then(() => sendResponse({ ok: true }));
|
|
2709
|
-
return true;
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
return false;
|
|
2713
|
-
});
|
|
2642
|
+
if (message?.type === 'bak.getState') {
|
|
2643
|
+
void buildPopupState().then((state) => {
|
|
2644
|
+
sendResponse(state);
|
|
2645
|
+
});
|
|
2646
|
+
return true;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
if (message?.type === 'bak.disconnect') {
|
|
2650
|
+
manualDisconnect = true;
|
|
2651
|
+
clearReconnectTimer();
|
|
2652
|
+
reconnectAttempt = 0;
|
|
2653
|
+
lastError = null;
|
|
2654
|
+
ws?.close();
|
|
2655
|
+
ws = null;
|
|
2656
|
+
sendResponse({ ok: true });
|
|
2657
|
+
return false;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
if (message?.type === 'bak.reconnectNow') {
|
|
2661
|
+
manualDisconnect = false;
|
|
2662
|
+
clearReconnectTimer();
|
|
2663
|
+
reconnectAttempt = 0;
|
|
2664
|
+
ws?.close();
|
|
2665
|
+
ws = null;
|
|
2666
|
+
void connectWebSocket().then(() => sendResponse({ ok: true }));
|
|
2667
|
+
return true;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
return false;
|
|
2671
|
+
});
|
|
2714
2672
|
|
|
2715
2673
|
|