mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 12:41:53 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user