@flrande/bak-extension 0.6.9 → 0.6.11

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/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 previouslyFocusedWindow =
473
- options.focused === true
474
- ? null
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 pickSeedTab = async (windowId: number): Promise<chrome.tabs.Tab | null> => {
492
- const tabs = await chrome.tabs.query({ windowId });
493
- const newlyCreatedTab =
494
- windowId === previouslyFocusedWindow?.id
495
- ? tabs.find((tab) => typeof tab.id === 'number' && !previouslyFocusedTabIds.has(tab.id))
496
- : null;
497
- const normalizedDesiredUrl = normalizeComparableTabUrl(desiredUrl);
498
- return (
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: finalWindow.id!,
532
- focused: Boolean(finalWindow.focused),
533
- initialTabId: seedTab?.id ?? null
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
- async function handleRequest(request: CliRequest): Promise<unknown> {
1765
- const params = request.params ?? {};
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
- switch (request.method) {
1811
- case 'session.ping': {
1812
- return { ok: true, ts: Date.now() };
1813
- }
1814
- case 'tabs.list': {
1815
- const tabs = await chrome.tabs.query({});
1816
- return {
1817
- tabs: tabs
1818
- .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
1819
- .map((tab) => toTabInfo(tab))
1820
- };
1821
- }
1822
- case 'tabs.getActive': {
1823
- const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
1824
- const tab = tabs[0];
1825
- if (!tab || typeof tab.id !== 'number') {
1826
- return { tab: null };
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
- return {
1829
- tab: toTabInfo(tab)
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
- case 'tabs.get': {
1833
- const tabId = Number(params.tabId);
1834
- const tab = await chrome.tabs.get(tabId);
1835
- if (typeof tab.id !== 'number') {
1836
- throw toError('E_NOT_FOUND', 'Tab missing id');
1837
- }
1838
- return {
1839
- tab: toTabInfo(tab)
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
- case 'tabs.focus': {
1843
- const tabId = Number(params.tabId);
1844
- await chrome.tabs.update(tabId, { active: true });
1845
- return { ok: true };
1846
- }
1847
- case 'tabs.new': {
1848
- const tab = await chrome.tabs.create({
1849
- url: (params.url as string | undefined) ?? 'about:blank',
1850
- windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
1851
- active: params.active === true
1852
- });
1853
- if (params.addToGroup === true && typeof tab.id === 'number') {
1854
- const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
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
- tabId: tab.id,
1857
- windowId: tab.windowId,
1858
- groupId
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
- case 'tabs.close': {
1867
- const tabId = Number(params.tabId);
1868
- await chrome.tabs.remove(tabId);
1869
- return { ok: true };
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
- case 'sessionBinding.ensure': {
1872
- return preserveHumanFocus(params.focus !== true, async () => {
1873
- const result = await bindingManager.ensureBinding({
1874
- bindingId: String(params.bindingId ?? ''),
1875
- focus: params.focus === true,
1876
- initialUrl: typeof params.url === 'string' ? params.url : undefined,
1877
- label: typeof params.label === 'string' ? params.label : undefined
1878
- });
1879
- for (const tab of result.binding.tabs) {
1880
- void ensureNetworkDebugger(tab.id).catch(() => undefined);
1881
- }
1882
- emitSessionBindingUpdated(result.binding.id, 'ensure', result.binding);
1883
- return {
1884
- browser: result.binding,
1885
- created: result.created,
1886
- repaired: result.repaired,
1887
- repairActions: result.repairActions
1888
- };
1889
- });
1890
- }
1891
- case 'sessionBinding.info': {
1892
- return {
1893
- browser: await bindingManager.getBindingInfo(String(params.bindingId ?? ''))
1894
- };
1895
- }
1896
- case 'sessionBinding.openTab': {
1897
- const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
1898
- const opened = await preserveHumanFocus(params.focus !== true, async () => {
1899
- return await bindingManager.openTab({
1900
- bindingId: String(params.bindingId ?? ''),
1901
- url: expectedUrl,
1902
- active: params.active === true,
1903
- focus: params.focus === true,
1904
- label: typeof params.label === 'string' ? params.label : undefined
1905
- });
1906
- });
1907
- const finalized = await finalizeOpenedSessionBindingTab(opened, expectedUrl);
1908
- void ensureNetworkDebugger(finalized.tab.id).catch(() => undefined);
1909
- emitSessionBindingUpdated(finalized.binding.id, 'open-tab', finalized.binding);
1910
- return {
1911
- browser: finalized.binding,
1912
- tab: finalized.tab
1913
- };
1914
- }
1915
- case 'sessionBinding.listTabs': {
1916
- const listed = await bindingManager.listTabs(String(params.bindingId ?? ''));
1917
- return {
1918
- browser: listed.binding,
1919
- tabs: listed.tabs
1920
- };
1921
- }
1922
- case 'sessionBinding.getActiveTab': {
1923
- const active = await bindingManager.getActiveTab(String(params.bindingId ?? ''));
1924
- return {
1925
- browser: active.binding,
1926
- tab: active.tab
1927
- };
1928
- }
1929
- case 'sessionBinding.setActiveTab': {
1930
- const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.bindingId ?? ''));
1931
- void ensureNetworkDebugger(result.tab.id).catch(() => undefined);
1932
- emitSessionBindingUpdated(result.binding.id, 'set-active-tab', result.binding);
1933
- return {
1934
- browser: result.binding,
1935
- tab: result.tab
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