feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
@@ -0,0 +1,337 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type Listener = (details: Record<string, any>) => unknown;
const fixture = vi.hoisted(() => ({
storage: new Map<string, unknown>(),
storageFailure: undefined as Error | undefined,
storageGetGate: undefined as Promise<void> | undefined,
frames: new Map<string, { url: string; documentId?: string }>(),
tabs: new Map<number, { id: number; incognito: boolean; cookieStoreId?: string }>(),
listeners: {} as Record<string, Listener>,
storageSet: vi.fn(),
sendMessage: vi.fn(async () => undefined),
}));
vi.mock('wxt/browser', () => {
const event = (name: string) => ({
addListener: vi.fn((listener: Listener) => {
fixture.listeners[name] = listener;
}),
});
fixture.storageSet.mockImplementation(async (items: Record<string, unknown>) => {
if (fixture.storageFailure) throw fixture.storageFailure;
for (const [key, value] of Object.entries(structuredClone(items))) fixture.storage.set(key, value);
});
return {
browser: {
storage: {
session: {
get: vi.fn(async (key: string) => {
await fixture.storageGetGate;
return { [key]: structuredClone(fixture.storage.get(key)) };
}),
set: fixture.storageSet,
},
},
runtime: { sendMessage: fixture.sendMessage },
webRequest: {
onBeforeRequest: event('beforeRequest'),
onBeforeSendHeaders: event('beforeSendHeaders'),
onBeforeRedirect: event('beforeRedirect'),
onCompleted: event('completed'),
onErrorOccurred: event('errorOccurred'),
},
webNavigation: {
getFrame: vi.fn(async ({ tabId, frameId }: { tabId: number; frameId: number }) => fixture.frames.get(`${tabId}:${frameId}`) || null),
onCommitted: event('committed'),
},
tabs: {
get: vi.fn(async (tabId: number) => {
const tab = fixture.tabs.get(tabId);
if (!tab) throw new Error('tab unavailable');
return tab;
}),
onRemoved: event('removed'),
},
},
};
});
import {
NETWORK_CAPTURE_GLOBAL_MAX_BYTES,
NETWORK_CAPTURE_GLOBAL_MAX_ENTRIES,
clearNetworkRequests,
listNetworkRequests,
networkCaptureStatus,
rebindNetworkCapturesForGrant,
startNetworkCapture,
stopNetworkCapture,
} from './service';
const NOW = 4_102_444_800_000;
const trackedTabs = new Set<number>();
function setTarget(
tabId: number,
url = `https://site-${tabId}.example.test/page`,
documentId = `document-${tabId}`,
input: { incognito?: boolean; cookieStoreId?: string } = {},
): void {
fixture.frames.set(`${tabId}:0`, { url, documentId });
fixture.tabs.set(tabId, {
id: tabId,
incognito: input.incognito === true,
cookieStoreId: input.cookieStoreId,
});
}
async function start(
tabId: number,
options: Parameters<typeof startNetworkCapture>[1] = {},
owner?: Parameters<typeof startNetworkCapture>[2],
) {
trackedTabs.add(tabId);
return startNetworkCapture({ tabId, frameId: 0, documentId: fixture.frames.get(`${tabId}:0`)?.documentId }, options, owner);
}
function beforeRequest(
tabId: number,
requestId: string,
timeStamp: number,
input: { url?: string; type?: string; body?: ArrayBuffer } = {},
): void {
fixture.listeners.beforeRequest({
tabId,
frameId: 0,
documentId: fixture.frames.get(`${tabId}:0`)?.documentId,
requestId,
url: input.url || `https://site-${tabId}.example.test/api/${requestId}`,
method: 'POST',
type: input.type || 'xmlhttprequest',
timeStamp,
initiator: `https://site-${tabId}.example.test`,
requestBody: input.body ? { raw: [{ bytes: input.body }] } : undefined,
});
}
async function committed(tabId: number, url: string, documentId?: string): Promise<void> {
fixture.frames.set(`${tabId}:0`, { url, documentId });
fixture.listeners.committed({ tabId, frameId: 0, url, documentId, timeStamp: Date.now() });
await vi.advanceTimersByTimeAsync(0);
}
describe('network capture lifecycle, budget and persistence', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
fixture.storage.clear();
fixture.storageFailure = undefined;
fixture.storageGetGate = undefined;
fixture.frames.clear();
fixture.tabs.clear();
fixture.sendMessage.mockClear();
fixture.storageSet.mockClear();
});
afterEach(async () => {
for (const tabId of trackedTabs) {
await stopNetworkCapture({ tabId, frameId: 0 }).catch(() => undefined);
}
trackedTabs.clear();
await vi.advanceTimersByTimeAsync(251);
vi.clearAllTimers();
vi.useRealTimers();
});
it('moves a grant-owned capture to the refreshed document without restarting it', async () => {
setTarget(41, 'https://grant.example.test/profile', 'document-old');
const before = await start(
41,
{ captureHeaders: true, captureBody: true },
{ kind: 'grant', grantId: 'grant-1', expiresAt: NOW + 60_000 },
);
await rebindNetworkCapturesForGrant('grant-1', [
{ tabId: 41, frameId: 0, documentId: 'document-new' },
]);
const after = await networkCaptureStatus({ tabId: 41, frameId: 0, documentId: 'document-new' });
expect(after).toMatchObject({ active: true, startedAt: before.startedAt });
expect(after.target.documentId).toBe('document-new');
expect((await networkCaptureStatus({ tabId: 41, frameId: 0, documentId: 'document-old' })).active).toBe(false);
});
it('does not let a grant refresh mutate or delete an unrelated local capture', async () => {
setTarget(42, 'https://local.example.test/start', 'document-local');
await start(42);
await rebindNetworkCapturesForGrant('grant-1', [
{ tabId: 99, frameId: 0, documentId: 'unrelated-document' },
]);
expect((await networkCaptureStatus({ tabId: 42, frameId: 0, documentId: 'document-local' })).active).toBe(true);
});
it('continues a local capture across a same-origin document and fails closed across origins', async () => {
setTarget(43, 'https://navigation.example.test/start', 'document-start');
const before = await start(43);
await committed(43, 'https://navigation.example.test/next', 'document-next');
const continued = await networkCaptureStatus({ tabId: 43, frameId: 0, documentId: 'document-next' });
expect(continued).toMatchObject({ active: true, startedAt: before.startedAt });
await committed(43, 'https://different.example.test/landing', 'document-cross-origin');
expect((await networkCaptureStatus({ tabId: 43, frameId: 0, documentId: 'document-cross-origin' })).active).toBe(false);
});
it('does not retain a cross-origin navigation request before the commit boundary is processed', async () => {
setTarget(44, 'https://source.example.test/start', 'document-source');
await start(44);
beforeRequest(44, 'navigation-1', NOW + 1, {
type: 'main_frame',
url: 'https://destination.example.test/landing',
});
expect(await listNetworkRequests({ tabId: 44, frameId: 0, documentId: 'document-source' })).toEqual([]);
});
it('retains a same-document WebSocket handshake as protocol evidence', async () => {
setTarget(47, 'https://site-47.example.test/app', 'document-socket');
await start(47, { captureHeaders: true, captureBody: true });
beforeRequest(47, 'socket-1', NOW + 1, {
type: 'websocket',
url: 'wss://site-47.example.test/events',
});
const records = await listNetworkRequests({
tabId: 47,
frameId: 0,
documentId: 'document-socket',
});
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
requestId: 'socket-1',
resourceType: 'websocket',
url: 'wss://site-47.example.test/events',
});
});
it('detects a missed cross-origin commit when a document-less client asks for status after restart', async () => {
setTarget(46, 'https://before-restart.example.test/start', 'document-before');
await start(46);
fixture.frames.set('46:0', {
url: 'https://after-restart.example.test/landing',
documentId: 'document-after',
});
expect((await networkCaptureStatus({ tabId: 46, frameId: 0 })).active).toBe(false);
});
it('serializes only the record changed by a hot-path request phase', async () => {
setTarget(45);
await start(45, { captureHeaders: true, maxEntries: 200 });
for (let index = 0; index < 100; index += 1) beforeRequest(45, `request-${index}`, NOW + index);
const stringify = vi.spyOn(JSON, 'stringify');
fixture.listeners.beforeSendHeaders({
tabId: 45,
frameId: 0,
documentId: 'document-45',
requestId: 'request-99',
url: 'https://site-45.example.test/api/request-99',
method: 'POST',
type: 'xmlhttprequest',
timeStamp: NOW + 101,
requestHeaders: [{ name: 'X-Test', value: 'value' }],
});
expect(stringify).toHaveBeenCalledTimes(1);
stringify.mockRestore();
});
it('enforces a deterministic global record budget across sessions', async () => {
for (let tabId = 50; tabId < 56; tabId += 1) {
setTarget(tabId);
await start(tabId, { maxEntries: 200 });
for (let index = 0; index < 200; index += 1) {
beforeRequest(tabId, `request-${tabId}-${index}`, NOW + (tabId - 50) * 200 + index);
}
}
const statuses = await Promise.all([...Array(6)].map((_, index) => networkCaptureStatus({ tabId: 50 + index, frameId: 0 })));
expect(statuses.reduce((total, status) => total + status.count, 0)).toBe(NETWORK_CAPTURE_GLOBAL_MAX_ENTRIES);
expect(statuses.reduce((total, status) => total + status.droppedCount, 0)).toBe(200);
expect(statuses[0].count).toBe(0);
expect(statuses.at(-1)?.globalCount).toBe(NETWORK_CAPTURE_GLOBAL_MAX_ENTRIES);
});
it('enforces the aggregate byte budget without exceeding each session limit', async () => {
const body = new Uint8Array(64 * 1024).fill(97).buffer;
for (const tabId of [60, 61]) {
setTarget(tabId);
await start(tabId, { captureBody: true, maxBodyBytes: 64 * 1024, maxEntries: 200 });
}
for (let index = 0; index < 140; index += 1) {
const tabId = index % 2 === 0 ? 60 : 61;
beforeRequest(tabId, `large-${index}`, NOW + index, { body });
}
const left = await networkCaptureStatus({ tabId: 60, frameId: 0 });
const right = await networkCaptureStatus({ tabId: 61, frameId: 0 });
expect(right.globalRetainedBytes).toBeLessThanOrEqual(NETWORK_CAPTURE_GLOBAL_MAX_BYTES);
expect((left.droppedCount + right.droppedCount)).toBeGreaterThan(0);
});
it('reports a storage failure as degraded and recovers on the next mutation', async () => {
setTarget(70);
await start(70);
fixture.storageFailure = new Error('session quota exceeded');
await vi.advanceTimersByTimeAsync(251);
expect(await networkCaptureStatus({ tabId: 70, frameId: 0 })).toMatchObject({
active: true,
persistence: 'degraded',
persistenceError: 'session quota exceeded',
});
fixture.storageFailure = undefined;
beforeRequest(70, 'retry-request', NOW + 1);
await vi.advanceTimersByTimeAsync(251);
expect(await networkCaptureStatus({ tabId: 70, frameId: 0 })).toMatchObject({
active: true,
persistence: 'persisted',
});
});
it('restores the bounded session after a simulated service-worker restart', async () => {
setTarget(80);
await start(80, { captureHeaders: true });
beforeRequest(80, 'restored-request', NOW + 1);
await vi.advanceTimersByTimeAsync(251);
expect(fixture.storageSet).toHaveBeenCalled();
let releaseStorage: (() => void) | undefined;
fixture.storageGetGate = new Promise<void>((resolve) => {
releaseStorage = resolve;
});
vi.resetModules();
const fresh = await import('./service');
beforeRequest(80, 'queued-during-restore', NOW + 2);
releaseStorage?.();
await vi.advanceTimersByTimeAsync(0);
const restored = await fresh.networkCaptureStatus({ tabId: 80, frameId: 0, documentId: 'document-80' });
expect(restored).toMatchObject({ active: true, count: 2, persistence: 'pending' });
expect((await fresh.listNetworkRequests(restored.target)).map((record) => record.requestId)).toEqual([
'queued-during-restore',
'restored-request',
]);
await fresh.clearNetworkRequests(restored.target);
await fresh.stopNetworkCapture(restored.target);
});
});
+529 -70
View File
@@ -17,16 +17,48 @@ const MAX_BODY_BYTES = 64 * 1024;
const MAX_HEADER_COUNT = 256;
const MAX_HEADER_VALUE_LENGTH = 16 * 1024;
const MAX_HEADER_BYTES = 64 * 1024;
const MAX_SESSION_BYTES = 5 * 1024 * 1024;
const CAPTURED_RESOURCE_TYPES = ['xmlhttprequest', 'ping', 'other', 'main_frame', 'sub_frame'] as const;
export const NETWORK_CAPTURE_SESSION_MAX_BYTES = 5 * 1024 * 1024;
export const NETWORK_CAPTURE_GLOBAL_MAX_BYTES = 8 * 1024 * 1024;
export const NETWORK_CAPTURE_GLOBAL_MAX_ENTRIES = 1_000;
export const NETWORK_CAPTURE_MAX_SESSIONS = 64;
const MAX_DEFERRED_CAPTURE_EVENTS = 128;
const CAPTURED_RESOURCE_TYPES = [
'xmlhttprequest',
'ping',
'other',
'main_frame',
'sub_frame',
'websocket',
] as const;
type CapturePersistence = 'pending' | 'persisted' | 'memory-only' | 'degraded';
type CaptureOwner = { kind: 'local' } | { kind: 'grant'; grantId: string; expiresAt: number };
interface CaptureSession {
target: BrowserTarget;
origin: string;
isolationBoundary: string;
startedAt: number;
droppedCount: number;
options: NetworkCaptureOptions;
records: NetworkRequestRecord[];
owner: { kind: 'local' } | { kind: 'grant'; grantId: string; expiresAt: number };
owner: CaptureOwner;
retainedBytes: number;
recordBytes: Map<string, number>;
revision: number;
persistence: CapturePersistence;
persistenceError?: string;
}
interface PersistedCaptureSession {
target: BrowserTarget;
origin: string;
isolationBoundary: string;
startedAt: number;
droppedCount: number;
options: NetworkCaptureOptions;
records: NetworkRequestRecord[];
owner: CaptureOwner;
}
interface SessionStorageArea {
@@ -37,8 +69,15 @@ interface SessionStorageArea {
const captureSessions = new Map<number, CaptureSession>();
const sessionStorage = (browser.storage as unknown as { session?: SessionStorageArea }).session;
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
let persistInFlight = false;
let mutationRevision = 0;
let totalRecordCount = 0;
let totalRetainedBytes = 0;
let notifyTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const pendingNotificationTabs = new Set<number>();
const deferredCaptureEvents: Array<() => void> = [];
const deferredCaptureDrops = new Map<number, number>();
let restoreComplete = false;
function normalizedOptions(input?: Partial<NetworkCaptureOptions>): NetworkCaptureOptions {
return {
@@ -49,47 +88,262 @@ function normalizedOptions(input?: Partial<NetworkCaptureOptions>): NetworkCaptu
};
}
function isCaptureSession(value: unknown): value is CaptureSession {
function isNetworkRequestRecord(value: unknown): value is NetworkRequestRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const record = value as Partial<NetworkRequestRecord>;
return typeof record.id === 'string' && record.id.length > 0 && record.id.length <= 160
&& typeof record.requestId === 'string' && record.requestId.length > 0 && record.requestId.length <= 512
&& Number.isSafeInteger(record.tabId) && Number.isSafeInteger(record.frameId)
&& typeof record.url === 'string' && record.url.length <= 16_384
&& typeof record.method === 'string' && record.method.length <= 64
&& typeof record.resourceType === 'string' && record.resourceType.length <= 160
&& typeof record.startedAt === 'number'
&& typeof record.requestHeadersCaptured === 'boolean'
&& typeof record.requestBodyCaptured === 'boolean'
&& Array.isArray(record.redirects);
}
function isCaptureSession(value: unknown): value is PersistedCaptureSession {
if (!value || typeof value !== 'object') return false;
const session = value as Partial<CaptureSession>;
const session = value as Partial<PersistedCaptureSession>;
return Boolean(
session.target && Number.isSafeInteger(session.target.tabId) && Number.isSafeInteger(session.target.frameId)
&& typeof session.startedAt === 'number' && Array.isArray(session.records),
&& typeof session.origin === 'string' && /^https?:\/\//i.test(session.origin)
&& typeof session.isolationBoundary === 'string' && session.isolationBoundary.length > 0
&& typeof session.startedAt === 'number' && Array.isArray(session.records)
&& session.records.every(isNetworkRequestRecord),
);
}
function encodedBytes(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
}
function recordSize(record: NetworkRequestRecord): number {
try {
return encodedBytes(record);
} catch {
return NETWORK_CAPTURE_SESSION_MAX_BYTES + 1;
}
}
function originOf(url: string): string | undefined {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.origin : undefined;
} catch {
return undefined;
}
}
function isolationBoundaryForTab(tab: Browser.tabs.Tab): string {
const cookieStoreId = typeof (tab as Browser.tabs.Tab & { cookieStoreId?: unknown }).cookieStoreId === 'string'
? String((tab as Browser.tabs.Tab & { cookieStoreId?: string }).cookieStoreId)
: 'default';
return `${tab.incognito ? 'private' : 'regular'}:${cookieStoreId}`;
}
async function captureBoundary(target: BrowserTarget): Promise<{
target: BrowserTarget;
origin: string;
isolationBoundary: string;
}> {
const [frame, tab] = await Promise.all([
browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId }),
browser.tabs.get(target.tabId),
]);
const origin = frame?.url ? originOf(frame.url) : undefined;
if (!frame || !origin) throw new ExtensionError('target_unavailable', '网络捕获只能绑定到可访问的 HTTP(S) 文档');
if (target.documentId && frame.documentId && target.documentId !== frame.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新选择');
}
return {
target: { tabId: target.tabId, frameId: target.frameId, documentId: frame.documentId || target.documentId },
origin,
isolationBoundary: isolationBoundaryForTab(tab),
};
}
function persistedSession(session: CaptureSession): PersistedCaptureSession {
return {
target: session.target,
origin: session.origin,
isolationBoundary: session.isolationBoundary,
startedAt: session.startedAt,
droppedCount: session.droppedCount,
options: session.options,
records: session.records,
owner: session.owner,
};
}
function notifySessions(sessions: Iterable<CaptureSession>): void {
for (const session of sessions) notifyChanged(session.target.tabId);
}
function schedulePersist(): void {
if (!sessionStorage || persistTimer || persistInFlight) return;
persistTimer = globalThis.setTimeout(() => {
persistTimer = undefined;
const snapshotRevision = mutationRevision;
const snapshot = [...captureSessions.values()].map(persistedSession);
persistInFlight = true;
void Promise.resolve().then(() => sessionStorage.set({ [NETWORK_CAPTURE_STORAGE_KEY]: snapshot })).then(() => {
const changed: CaptureSession[] = [];
for (const session of captureSessions.values()) {
if (session.revision > snapshotRevision || session.persistence === 'persisted') continue;
session.persistence = 'persisted';
session.persistenceError = undefined;
changed.push(session);
}
notifySessions(changed);
}).catch((error) => {
const message = (error instanceof Error ? error.message : String(error)).slice(0, 512);
const changed: CaptureSession[] = [];
for (const session of captureSessions.values()) {
session.persistence = 'degraded';
session.persistenceError = message;
changed.push(session);
}
notifySessions(changed);
}).finally(() => {
persistInFlight = false;
if (mutationRevision > snapshotRevision) schedulePersist();
});
}, 250);
}
function markDirty(sessions: Iterable<CaptureSession> = []): void {
mutationRevision += 1;
for (const session of sessions) {
session.revision = mutationRevision;
if (!sessionStorage) {
session.persistence = 'memory-only';
session.persistenceError = undefined;
} else if (session.persistence !== 'degraded') {
session.persistence = 'pending';
session.persistenceError = undefined;
}
}
schedulePersist();
}
function unregisterSessionRecords(session: CaptureSession): void {
totalRecordCount = Math.max(0, totalRecordCount - session.records.length);
totalRetainedBytes = Math.max(0, totalRetainedBytes - session.retainedBytes);
session.records = [];
session.recordBytes.clear();
session.retainedBytes = 0;
}
function deleteSession(tabId: number): CaptureSession | undefined {
const session = captureSessions.get(tabId);
if (!session) return undefined;
unregisterSessionRecords(session);
captureSessions.delete(tabId);
return session;
}
function addRestoredSession(value: PersistedCaptureSession): boolean {
if (value.owner?.kind === 'grant' && value.owner.expiresAt <= Date.now()) return false;
const records: NetworkRequestRecord[] = [];
const seenIds = new Set<string>();
for (let index = value.records.length - 1; index >= 0 && records.length < MAX_ENTRIES; index -= 1) {
const record = value.records[index];
if (seenIds.has(record.id)) continue;
seenIds.add(record.id);
records.unshift(record);
}
const recordBytes = new Map<string, number>();
let retainedBytes = 0;
for (const record of records) {
const bytes = recordSize(record);
recordBytes.set(record.id, bytes);
retainedBytes += bytes;
}
const session: CaptureSession = {
target: value.target,
origin: value.origin,
isolationBoundary: value.isolationBoundary,
startedAt: value.startedAt,
droppedCount: Number.isSafeInteger(value.droppedCount) ? Math.max(0, value.droppedCount) : 0,
options: normalizedOptions(value.options),
records,
owner: value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number'
? value.owner
: { kind: 'local' },
retainedBytes,
recordBytes,
revision: 0,
persistence: 'persisted',
};
const existing = captureSessions.get(session.target.tabId);
if (existing) unregisterSessionRecords(existing);
captureSessions.set(session.target.tabId, session);
totalRecordCount += records.length;
totalRetainedBytes += retainedBytes;
const ownerWasValid = value.owner?.kind === 'local'
|| (value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number');
return records.length === value.records.length && ownerWasValid && !existing;
}
async function restoreSessions(): Promise<void> {
if (!sessionStorage) return;
let repaired = false;
try {
const stored = await sessionStorage.get(NETWORK_CAPTURE_STORAGE_KEY);
const sessions = stored[NETWORK_CAPTURE_STORAGE_KEY];
if (!Array.isArray(sessions)) return;
for (const value of sessions) {
if (!isCaptureSession(value)) continue;
const session: CaptureSession = {
...value,
droppedCount: Number.isSafeInteger(value.droppedCount) ? value.droppedCount : 0,
options: normalizedOptions(value.options),
records: value.records.slice(-MAX_ENTRIES),
owner: value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number'
? value.owner
: { kind: 'local' },
};
captureSessions.set(session.target.tabId, session);
if (!isCaptureSession(value)) {
repaired = true;
continue;
}
if (!addRestoredSession(value)) repaired = true;
}
const changed = enforceBudgets();
if (changed.size > 0) repaired = true;
if (captureSessions.size > NETWORK_CAPTURE_MAX_SESSIONS) repaired = true;
while (captureSessions.size > NETWORK_CAPTURE_MAX_SESSIONS) {
const oldest = [...captureSessions.values()].sort((left, right) => left.startedAt - right.startedAt || left.target.tabId - right.target.tabId)[0];
if (!oldest) break;
deleteSession(oldest.target.tabId);
}
if (repaired) markDirty(captureSessions.values());
} catch {
// Session persistence is an optimization; capture still works in memory.
}
}
const restorePromise = restoreSessions();
const restorePromise = restoreSessions().finally(() => {
restoreComplete = true;
for (const replay of deferredCaptureEvents.splice(0)) replay();
const changed = new Set<CaptureSession>();
for (const [tabId, count] of deferredCaptureDrops) {
const session = captureSessions.get(tabId);
if (!session) continue;
session.droppedCount += count;
changed.add(session);
notifyChanged(tabId);
}
deferredCaptureDrops.clear();
if (changed.size > 0) markDirty(changed);
});
function schedulePersist(): void {
if (!sessionStorage || persistTimer) return;
persistTimer = globalThis.setTimeout(() => {
persistTimer = undefined;
void sessionStorage.set({ [NETWORK_CAPTURE_STORAGE_KEY]: [...captureSessions.values()] }).catch(() => undefined);
}, 250);
function dispatchCaptureEvent<T extends { tabId: number }>(handler: (details: T) => unknown, details: T): void {
if (restoreComplete) {
handler(details);
return;
}
if (deferredCaptureEvents.length >= MAX_DEFERRED_CAPTURE_EVENTS) {
deferredCaptureDrops.set(details.tabId, (deferredCaptureDrops.get(details.tabId) || 0) + 1);
return;
}
let snapshot = details;
try {
snapshot = structuredClone(details);
} catch { /* WebRequest details remain valid for the current event-loop turn. */ }
deferredCaptureEvents.push(() => handler(snapshot));
}
function notifyChanged(tabId: number): void {
@@ -105,17 +359,20 @@ function notifyChanged(tabId: number): void {
}, 100);
}
function matchingSession(details: Pick<Browser.webRequest.WebRequestDetails, 'tabId' | 'frameId' | 'type'> & { documentId?: string }): CaptureSession | undefined {
function matchingSession(details: Pick<Browser.webRequest.WebRequestDetails, 'tabId' | 'frameId' | 'type' | 'url' | 'initiator'> & { documentId?: string }): CaptureSession | undefined {
const session = captureSessions.get(details.tabId);
if (!session || details.frameId !== session.target.frameId) return undefined;
if (session.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
captureSessions.delete(details.tabId);
schedulePersist();
deleteSession(details.tabId);
markDirty();
notifyChanged(details.tabId);
return undefined;
}
const isFrameNavigation = details.type === 'main_frame' || details.type === 'sub_frame';
if (isFrameNavigation && originOf(details.url) !== session.origin) return undefined;
if (!isFrameNavigation && session.target.documentId && details.documentId && session.target.documentId !== details.documentId) return undefined;
if (!isFrameNavigation && !details.documentId && details.initiator
&& originOf(details.initiator) !== session.origin) return undefined;
return session;
}
@@ -191,19 +448,70 @@ function findRecord(session: CaptureSession, requestId: string): NetworkRequestR
return undefined;
}
function commit(session: CaptureSession, tabId: number): void {
while (session.records.length > session.options.maxEntries) {
session.records.shift();
session.droppedCount += 1;
function refreshRecordAccounting(session: CaptureSession, record: NetworkRequestRecord): void {
const previousBytes = session.recordBytes.get(record.id);
const nextBytes = recordSize(record);
session.recordBytes.set(record.id, nextBytes);
if (previousBytes === undefined) {
totalRecordCount += 1;
session.retainedBytes += nextBytes;
totalRetainedBytes += nextBytes;
return;
}
let estimatedBytes = session.records.reduce((total, record) => total + JSON.stringify(record).length, 0);
while (estimatedBytes > MAX_SESSION_BYTES && session.records.length > 1) {
const removed = session.records.shift();
estimatedBytes -= removed ? JSON.stringify(removed).length : 0;
session.droppedCount += 1;
const delta = nextBytes - previousBytes;
session.retainedBytes += delta;
totalRetainedBytes += delta;
}
function removeRecordAt(session: CaptureSession, index: number): NetworkRequestRecord | undefined {
const [removed] = session.records.splice(index, 1);
if (!removed) return undefined;
const bytes = session.recordBytes.get(removed.id) ?? recordSize(removed);
session.recordBytes.delete(removed.id);
session.retainedBytes = Math.max(0, session.retainedBytes - bytes);
totalRecordCount = Math.max(0, totalRecordCount - 1);
totalRetainedBytes = Math.max(0, totalRetainedBytes - bytes);
session.droppedCount += 1;
return removed;
}
function enforceBudgets(changed = new Set<CaptureSession>()): Set<CaptureSession> {
for (const session of captureSessions.values()) {
while (session.records.length > session.options.maxEntries
|| session.retainedBytes > NETWORK_CAPTURE_SESSION_MAX_BYTES) {
if (!removeRecordAt(session, 0)) break;
changed.add(session);
}
}
schedulePersist();
while (totalRecordCount > NETWORK_CAPTURE_GLOBAL_MAX_ENTRIES
|| totalRetainedBytes > NETWORK_CAPTURE_GLOBAL_MAX_BYTES) {
let candidate: { session: CaptureSession; index: number; record: NetworkRequestRecord } | undefined;
for (const session of captureSessions.values()) {
for (let index = 0; index < session.records.length; index += 1) {
const record = session.records[index];
if (!candidate
|| record.startedAt < candidate.record.startedAt
|| (record.startedAt === candidate.record.startedAt && session.target.tabId < candidate.session.target.tabId)
|| (record.startedAt === candidate.record.startedAt && session.target.tabId === candidate.session.target.tabId
&& record.id.localeCompare(candidate.record.id) < 0)) {
candidate = { session, index, record };
}
}
}
if (!candidate || !removeRecordAt(candidate.session, candidate.index)) break;
changed.add(candidate.session);
}
return changed;
}
function commit(session: CaptureSession, tabId: number, record: NetworkRequestRecord): void {
refreshRecordAccounting(session, record);
const changed = enforceBudgets(new Set([session]));
markDirty(changed);
notifyChanged(tabId);
for (const affected of changed) {
if (affected.target.tabId !== tabId) notifyChanged(affected.target.tabId);
}
}
function onBeforeRequest(details: Browser.webRequest.OnBeforeRequestDetails): Browser.webRequest.BlockingResponse | undefined {
@@ -224,7 +532,7 @@ function onBeforeRequest(details: Browser.webRequest.OnBeforeRequestDetails): Br
record.startedAt = details.timeStamp;
}
if (session.options.captureBody) record.requestBody = requestBody(details, session.options.maxBodyBytes);
commit(session, details.tabId);
commit(session, details.tabId, record);
return undefined;
}
@@ -234,7 +542,7 @@ function onBeforeSendHeaders(details: Browser.webRequest.OnBeforeSendHeadersDeta
const record = findRecord(session, details.requestId);
if (!record) return undefined;
record.requestHeaders = normalizeHeaders(details.requestHeaders);
commit(session, details.tabId);
commit(session, details.tabId, record);
return undefined;
}
@@ -246,7 +554,7 @@ function onBeforeRedirect(details: Browser.webRequest.OnBeforeRedirectDetails):
record.redirects.push({ url: details.url, statusCode: details.statusCode, redirectUrl: details.redirectUrl, timestamp: details.timeStamp });
record.statusCode = details.statusCode;
record.statusLine = details.statusLine;
commit(session, details.tabId);
commit(session, details.tabId, record);
}
function completeRecord(details: Browser.webRequest.OnCompletedDetails): void {
@@ -265,7 +573,7 @@ function completeRecord(details: Browser.webRequest.OnCompletedDetails): void {
const contentLength = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-length')?.value;
record.responseContentType = contentType?.slice(0, 512);
if (contentLength && Number.isSafeInteger(Number(contentLength))) record.responseSize = Number(contentLength);
commit(session, details.tabId);
commit(session, details.tabId, record);
}
function errorRecord(details: Browser.webRequest.OnErrorOccurredDetails): void {
@@ -276,16 +584,67 @@ function errorRecord(details: Browser.webRequest.OnErrorOccurredDetails): void {
record.completedAt = details.timeStamp;
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
record.error = details.error.slice(0, 512);
commit(session, details.tabId);
commit(session, details.tabId, record);
}
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestBody']);
browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestHeaders', 'extraHeaders']);
browser.webRequest.onBeforeRedirect.addListener(onBeforeRedirect, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onCompleted.addListener(completeRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onErrorOccurred.addListener(errorRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] });
browser.webRequest.onBeforeRequest.addListener((details) => {
dispatchCaptureEvent(onBeforeRequest, details);
return undefined;
}, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestBody']);
browser.webRequest.onBeforeSendHeaders.addListener((details) => {
dispatchCaptureEvent(onBeforeSendHeaders, details);
return undefined;
}, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestHeaders', 'extraHeaders']);
browser.webRequest.onBeforeRedirect.addListener((details) => {
dispatchCaptureEvent(onBeforeRedirect, details);
}, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onCompleted.addListener((details) => {
dispatchCaptureEvent(completeRecord, details);
}, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onErrorOccurred.addListener((details) => {
dispatchCaptureEvent(errorRecord, details);
}, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] });
async function rebindLocalCaptureAfterNavigation(details: {
tabId: number;
frameId: number;
documentId?: string;
url: string;
}): Promise<void> {
await restorePromise;
const session = captureSessions.get(details.tabId);
if (!session || session.owner.kind !== 'local' || session.target.frameId !== details.frameId) return;
if (details.documentId && session.target.documentId === details.documentId) return;
let isolationBoundary: string | undefined;
try {
isolationBoundary = isolationBoundaryForTab(await browser.tabs.get(details.tabId));
} catch {
isolationBoundary = undefined;
}
if (captureSessions.get(details.tabId) !== session) return;
const nextOrigin = originOf(details.url);
if (!nextOrigin || nextOrigin !== session.origin || isolationBoundary !== session.isolationBoundary) {
deleteSession(details.tabId);
markDirty();
notifyChanged(details.tabId);
return;
}
session.target = {
tabId: details.tabId,
frameId: details.frameId,
documentId: details.documentId,
};
markDirty([session]);
notifyChanged(details.tabId);
}
browser.webNavigation.onCommitted.addListener((details) => {
void rebindLocalCaptureAfterNavigation(details).catch(() => undefined);
});
browser.tabs.onRemoved.addListener((tabId) => {
if (captureSessions.delete(tabId)) schedulePersist();
if (!deleteSession(tabId)) return;
markDirty();
pendingNotificationTabs.delete(tabId);
});
function sameTarget(left: BrowserTarget, right: BrowserTarget): boolean {
@@ -293,51 +652,116 @@ function sameTarget(left: BrowserTarget, right: BrowserTarget): boolean {
&& (!left.documentId || !right.documentId || left.documentId === right.documentId);
}
function sessionFor(target: BrowserTarget): CaptureSession | undefined {
async function sessionFor(target: BrowserTarget): Promise<CaptureSession | undefined> {
const session = captureSessions.get(target.tabId);
if (session?.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
captureSessions.delete(target.tabId);
schedulePersist();
deleteSession(target.tabId);
markDirty();
notifyChanged(target.tabId);
return undefined;
}
return session && sameTarget(session.target, target) ? session : undefined;
if (!session) return undefined;
const sameFrame = session.target.tabId === target.tabId && session.target.frameId === target.frameId;
const sameDocument = Boolean(
session.target.documentId
&& target.documentId
&& session.target.documentId === target.documentId,
);
if (sameTarget(session.target, target) && (session.owner.kind === 'grant' || sameDocument)) return session;
if (session.owner.kind !== 'local'
|| !sameFrame) return undefined;
let boundary: Awaited<ReturnType<typeof captureBoundary>> | undefined;
try {
boundary = await captureBoundary(target);
} catch {
boundary = undefined;
}
if (captureSessions.get(target.tabId) !== session) return undefined;
if (!boundary || boundary.origin !== session.origin || boundary.isolationBoundary !== session.isolationBoundary) {
deleteSession(target.tabId);
markDirty();
notifyChanged(target.tabId);
return undefined;
}
if (sameTarget(session.target, boundary.target)
&& session.target.documentId === boundary.target.documentId) return session;
session.target = boundary.target;
markDirty([session]);
notifyChanged(target.tabId);
return session;
}
export async function startNetworkCapture(
target: BrowserTarget,
options?: Partial<NetworkCaptureOptions>,
owner: CaptureSession['owner'] = { kind: 'local' },
owner: CaptureOwner = { kind: 'local' },
): Promise<NetworkCaptureStatus> {
await restorePromise;
const session: CaptureSession = { target, startedAt: Date.now(), droppedCount: 0, options: normalizedOptions(options), records: [], owner };
captureSessions.set(target.tabId, session);
schedulePersist();
notifyChanged(target.tabId);
return networkCaptureStatus(target);
if (owner.kind === 'grant' && owner.expiresAt <= Date.now()) {
throw new ExtensionError('grant_expired', '浏览器共享会话已经过期,无法开始网络捕获');
}
const boundary = await captureBoundary(target);
if (captureSessions.has(target.tabId)) deleteSession(target.tabId);
while (captureSessions.size >= NETWORK_CAPTURE_MAX_SESSIONS) {
const oldest = [...captureSessions.values()].sort((left, right) => left.startedAt - right.startedAt || left.target.tabId - right.target.tabId)[0];
if (!oldest) break;
deleteSession(oldest.target.tabId);
notifyChanged(oldest.target.tabId);
}
const session: CaptureSession = {
target: boundary.target,
origin: boundary.origin,
isolationBoundary: boundary.isolationBoundary,
startedAt: Date.now(),
droppedCount: 0,
options: normalizedOptions(options),
records: [],
owner,
retainedBytes: 0,
recordBytes: new Map(),
revision: 0,
persistence: sessionStorage ? 'pending' : 'memory-only',
};
captureSessions.set(boundary.target.tabId, session);
markDirty([session]);
notifyChanged(boundary.target.tabId);
return networkCaptureStatus(boundary.target);
}
export async function networkCaptureStatus(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
const session = sessionFor(target);
const session = await sessionFor(target);
return session
? { active: true, target: session.target, startedAt: session.startedAt, count: session.records.length, droppedCount: session.droppedCount, options: session.options }
? {
active: true,
target: session.target,
startedAt: session.startedAt,
count: session.records.length,
droppedCount: session.droppedCount,
options: session.options,
retainedBytes: session.retainedBytes,
globalCount: totalRecordCount,
globalRetainedBytes: totalRetainedBytes,
persistence: session.persistence,
persistenceError: session.persistenceError,
}
: { active: false, target, count: 0, droppedCount: 0 };
}
export async function listNetworkRequests(target: BrowserTarget, limit = 100): Promise<NetworkRequestRecord[]> {
await restorePromise;
const session = sessionFor(target);
const session = await sessionFor(target);
if (!session) return [];
return structuredClone(session.records.slice(-Math.min(Math.max(limit, 1), MAX_ENTRIES)).reverse());
}
export async function clearNetworkRequests(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
const session = sessionFor(target);
const session = await sessionFor(target);
if (session) {
session.records = [];
unregisterSessionRecords(session);
session.droppedCount = 0;
schedulePersist();
markDirty([session]);
notifyChanged(target.tabId);
}
return networkCaptureStatus(target);
@@ -345,9 +769,12 @@ export async function clearNetworkRequests(target: BrowserTarget): Promise<Netwo
export async function stopNetworkCapture(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
captureSessions.delete(target.tabId);
schedulePersist();
notifyChanged(target.tabId);
const session = await sessionFor(target);
if (session) {
deleteSession(session.target.tabId);
markDirty();
notifyChanged(session.target.tabId);
}
return { active: false, target, count: 0, droppedCount: 0 };
}
@@ -356,11 +783,43 @@ export async function stopNetworkCapturesForGrant(grantId: string): Promise<void
let changed = false;
for (const [tabId, session] of captureSessions) {
if (session.owner.kind !== 'grant' || session.owner.grantId !== grantId) continue;
captureSessions.delete(tabId);
deleteSession(tabId);
notifyChanged(tabId);
changed = true;
}
if (changed) schedulePersist();
if (changed) markDirty();
}
export async function rebindNetworkCapturesForGrant(
grantId: string,
targets: readonly BrowserTarget[],
): Promise<void> {
await restorePromise;
const targetsByFrame = new Map(
targets.map((target) => [`${target.tabId}:${target.frameId}`, target] as const),
);
let changed = false;
const dirtySessions = new Set<CaptureSession>();
for (const [tabId, session] of captureSessions) {
if (session.owner.kind !== 'grant' || session.owner.grantId !== grantId) continue;
const target = targetsByFrame.get(`${session.target.tabId}:${session.target.frameId}`);
if (!target) {
deleteSession(tabId);
notifyChanged(tabId);
changed = true;
continue;
}
if (sameTarget(session.target, target)) continue;
session.target = {
tabId: target.tabId,
frameId: target.frameId,
documentId: target.documentId,
};
dirtySessions.add(session);
notifyChanged(tabId);
changed = true;
}
if (changed) markDirty(dirtySessions);
}
function bodyBytes(body?: NetworkBody): Uint8Array {
@@ -370,7 +829,7 @@ function bodyBytes(body?: NetworkBody): Uint8Array {
export async function exportNetworkRequest(target: BrowserTarget, id: string): Promise<NetworkRequestExport> {
await restorePromise;
const session = sessionFor(target);
const session = await sessionFor(target);
const record = session?.records.find((item) => item.id === id);
if (!record) throw new ExtensionError('network_request_not_found', '网络请求不存在或已经被有界缓冲区淘汰');
if (!record.requestHeadersCaptured || !record.requestHeaders) {