feat(agent-browser): add opt-in preview data injection

This commit is contained in:
2026-08-26 22:36:54 +08:00
parent 14fec70108
commit 38d63a9b7b
11 changed files with 1024 additions and 3 deletions

View File

@@ -15,6 +15,14 @@ import type {
AgentBrowserViewPort,
PortListener,
} from './adapter';
import type {
PreviewDataInjectionValue,
PreviewDataSessionInvalidationListener,
PreviewDataSessionInvalidationReason,
PreviewDataSessionManager,
PreviewDataSessionOpenInput,
PreviewDataSessionSnapshot,
} from '../services/preview-data-session';
import { AgentBrowserCdpGuard } from './cdp-guard';
import { AgentBrowserEventBuffer } from './event-buffer';
import { AgentBrowserFault } from './fault';
@@ -111,14 +119,34 @@ interface BrowserRecord {
listener: PortListener;
}>;
interruptWaiters: Set<(fault: AgentBrowserFault) => void>;
previewDataRequested: boolean;
previewData?: PreviewDataBinding;
}
type PreviewDataSession = Pick<
PreviewDataSessionManager,
'open' | 'getInjectionValue' | 'invalidate' | 'subscribeInvalidation'
>;
type PreviewDataScript = Readonly<{
identifier: string;
sessionRef?: string;
}>;
type PreviewDataBinding = {
session: PreviewDataSessionSnapshot;
scripts: PreviewDataScript[];
invalidated: boolean;
cleanup?: Promise<void>;
};
export interface AgentBrowserOpenInput {
projectId: string;
projectPath: string;
url: string;
bounds?: AgentBrowserBounds;
visible?: boolean;
injectProjectData?: boolean;
}
export interface AgentBrowserPresentInput {
@@ -160,6 +188,7 @@ export interface AgentBrowserModuleOptions {
payloadStore?: AgentBrowserPayloadStore;
cdpGuard?: AgentBrowserCdpGuard;
onLifecycle?(event: AgentBrowserLifecycleEvent): void;
previewDataSession?: PreviewDataSession;
}
export type AgentBrowserLifecycleEvent = Readonly<{
@@ -187,6 +216,8 @@ export class AgentBrowserModule {
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
private readonly lifecycleListeners = new Set<AgentBrowserLifecycleListener>();
private record: BrowserRecord | null = null;
private previewDataSession: PreviewDataSession | undefined;
private previewDataSessionUnsubscribe: (() => void) | undefined;
private commandTail: Promise<void> = Promise.resolve();
private lifecycleBarrier: Promise<void> = Promise.resolve();
private queueEpoch = 0;
@@ -200,6 +231,21 @@ export class AgentBrowserModule {
this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore();
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
if (options.onLifecycle) this.lifecycleListeners.add(options.onLifecycle);
if (options.previewDataSession) this.configurePreviewDataSession(options.previewDataSession);
}
configurePreviewDataSession(session?: PreviewDataSession): void {
this.previewDataSessionUnsubscribe?.();
this.previewDataSessionUnsubscribe = undefined;
this.previewDataSession = session;
if (!session) return;
const onInvalidated: PreviewDataSessionInvalidationListener = (_reason) => {
const record = this.record;
if (!record?.previewData || record.previewData.invalidated) return;
record.previewData.invalidated = true;
void this.removePreviewDataScripts(record, record.previewData).catch(() => undefined);
};
this.previewDataSessionUnsubscribe = session.subscribeInvalidation(onInvalidated);
}
subscribeLifecycle(listener: AgentBrowserLifecycleListener): () => void {
@@ -263,6 +309,8 @@ export class AgentBrowserModule {
const projectPath = normalizeRequiredPath(input.projectPath);
const targetUrl = normalizeUrl(input.url);
const bounds = input.bounds ? normalizeBounds(input.bounds) : null;
const injectProjectData = input.injectProjectData === true;
if (injectProjectData) assertPreviewDataTarget(targetUrl);
if (this.record && !samePath(this.record.projectPath, projectPath)) {
await this.closeInternal();
@@ -281,6 +329,17 @@ export class AgentBrowserModule {
await this.closeInternal();
}
}
if (this.record) {
const record = this.record;
const shouldReplacePreview = injectProjectData
? !record.previewData
|| record.previewData.invalidated
|| !sameOrigin(record.previewData.session.origin, targetUrl)
: Boolean(record.previewData);
if (shouldReplacePreview) {
await this.closeInternal(record);
}
}
if (this.record) {
const record = this.record;
if (bounds) {
@@ -312,6 +371,8 @@ export class AgentBrowserModule {
webContentsListeners: [],
debuggerListeners: [],
interruptWaiters: new Set(),
previewDataRequested: injectProjectData,
previewData: undefined,
};
this.record = record;
@@ -332,6 +393,7 @@ export class AgentBrowserModule {
this.attachDebugger(record, false),
OPEN_TIMEOUT_MS,
);
if (injectProjectData) await this.installPreviewData(record);
} catch (error) {
await this.closeInternal(record);
throw toFault(
@@ -541,6 +603,9 @@ export class AgentBrowserModule {
this.disposed = true;
this.preemptCommands('开发浏览器模块已关闭。');
await this.closeInternal();
this.previewDataSessionUnsubscribe?.();
this.previewDataSessionUnsubscribe = undefined;
this.previewDataSession = undefined;
this.payloadStore.clear();
}
@@ -694,6 +759,7 @@ export class AgentBrowserModule {
record.generation = ++this.generation;
record.childSessions.clear();
record.ioHandles.clear();
await this.invalidatePreviewData(record, 'browser_generation_replaced');
this.notifyLifecycle({
type: 'generation-replaced',
projectId: record.projectId,
@@ -708,7 +774,7 @@ export class AgentBrowserModule {
await port.sendCommand('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
waitForDebuggerOnStart: record.previewDataRequested,
flatten: true,
});
if (record.diagnosticsEnabled) {
@@ -729,6 +795,195 @@ export class AgentBrowserModule {
record.error = undefined;
}
private async installPreviewData(
record: BrowserRecord,
sessionRef?: string,
): Promise<void> {
if (record !== this.record || record.view.webContents.isDestroyed()) {
throw new AgentBrowserFault(
'TARGET_GONE',
'开发浏览器页面已关闭。',
true,
record.generation,
);
}
const previewDataSession = this.previewDataSession;
if (!previewDataSession) {
throw new Error('Preview data session is not configured');
}
let binding = record.previewData;
if (!sessionRef) {
const openInput: PreviewDataSessionOpenInput = {
projectPath: record.projectPath,
origin: new URL(record.url).origin,
browserGeneration: record.generation,
};
const session = await previewDataSession.open(openInput);
if (
record !== this.record
|| record.state === 'closing'
|| record.view.webContents.isDestroyed()
) {
previewDataSession.invalidate('preview_closed');
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器页面在创建数据运行时期间已关闭或切换。',
true,
record.generation,
'unknown',
);
}
const value = previewDataSession.getInjectionValue();
if (!value) {
previewDataSession.invalidate('manual');
throw new Error('Preview data injection value is unavailable');
}
binding = {
session,
scripts: [],
invalidated: false,
};
record.previewData = binding;
try {
await this.addPreviewDataScript(record, binding, value);
} catch (error) {
await this.invalidatePreviewData(record, 'manual');
throw error;
}
return;
}
if (!binding || binding.invalidated) return;
const value = previewDataSession.getInjectionValue();
if (!value) return;
await this.addPreviewDataScript(record, binding, value, sessionRef);
}
private async addPreviewDataScript(
record: BrowserRecord,
binding: PreviewDataBinding,
value: PreviewDataInjectionValue,
sessionRef?: string,
): Promise<void> {
const response = await record.view.webContents.debugger.sendCommand(
'Page.addScriptToEvaluateOnNewDocument',
{ source: previewDataInjectionScript(binding.session.origin, value) },
sessionRef,
);
const identifier = isRecord(response) && typeof response.identifier === 'string'
? response.identifier
: undefined;
if (!identifier) throw new Error('Preview data injection script was not registered');
const script = {
identifier,
...(sessionRef ? { sessionRef } : {}),
};
if (
record !== this.record
|| record.view.webContents.isDestroyed()
|| binding.invalidated
|| record.previewData !== binding
) {
await record.view.webContents.debugger.sendCommand(
'Page.removeScriptToEvaluateOnNewDocument',
{ identifier },
sessionRef,
).catch(() => undefined);
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器页面在安装数据运行时期间已关闭或切换。',
true,
record.generation,
'unknown',
);
}
binding.scripts.push(script);
}
private invalidatePreviewData(
record: BrowserRecord,
reason: PreviewDataSessionInvalidationReason,
): Promise<void> {
const binding = record.previewData;
if (!binding) return Promise.resolve();
if (!binding.invalidated) {
binding.invalidated = true;
this.previewDataSession?.invalidate(reason);
}
return this.removePreviewDataScripts(record, binding);
}
private removePreviewDataScripts(
record: BrowserRecord,
binding: PreviewDataBinding,
): Promise<void> {
if (binding.cleanup) return binding.cleanup;
binding.cleanup = (async () => {
record.previewDataRequested = false;
const scripts = binding.scripts.splice(0);
await Promise.all(scripts.map(async ({ identifier, sessionRef }) => {
try {
await record.view.webContents.debugger.sendCommand(
'Page.removeScriptToEvaluateOnNewDocument',
{ identifier },
sessionRef,
);
} catch {
// The debugger or child target may already be detached.
}
if (sessionRef) {
await record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
sessionRef,
).catch(() => undefined);
}
}));
if (
record === this.record
&& !record.view.webContents.isDestroyed()
&& record.view.webContents.debugger.isAttached()
) {
await record.view.webContents.debugger.sendCommand('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
}).catch(() => undefined);
}
if (record.previewData === binding) record.previewData = undefined;
})();
return binding.cleanup;
}
private isPreviewDataCrossOrigin(record: BrowserRecord, nextUrl: string): boolean {
const origin = record.previewData?.session.origin;
if (!origin) return false;
return !sameOrigin(origin, nextUrl);
}
private handleCrossOriginNavigationRequest(
record: BrowserRecord,
nextUrl: string,
eventValue: unknown,
): void {
if (!this.isPreviewDataCrossOrigin(record, nextUrl)) return;
const binding = record.previewData;
if (!binding || binding.invalidated) return;
const event = preventableEvent(eventValue);
event?.preventDefault();
const cleanup = this.invalidatePreviewData(record, 'cross_origin_navigation');
if (!event) return;
void cleanup.then(() => {
if (
record !== this.record
|| record.view.webContents.isDestroyed()
|| record.state === 'closing'
) return;
void this.navigateTo(record, nextUrl).catch(() => undefined);
});
}
private registerListeners(record: BrowserRecord): void {
const contents = record.view.webContents;
const onDebuggerMessage: PortListener = (
@@ -747,6 +1002,7 @@ export class AgentBrowserModule {
if (record !== this.record || record.state === 'closing' || record.state === 'crashed') {
return;
}
this.invalidatePreviewData(record, 'browser_detached');
record.eventBuffer.markGap('debugger-detached');
record.childSessions.clear();
record.ioHandles.clear();
@@ -770,10 +1026,22 @@ export class AgentBrowserModule {
this.addDebuggerListener(record, 'message', onDebuggerMessage);
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
this.addWebContentsListener(record, 'will-navigate', (eventValue, urlValue, _isInPlace, isMainFrameValue) => {
if (isMainFrameValue === false || typeof urlValue !== 'string') return;
this.handleCrossOriginNavigationRequest(record, urlValue, eventValue);
});
this.addWebContentsListener(record, 'will-redirect', (eventValue, urlValue, _isInPlace, isMainFrameValue) => {
if (isMainFrameValue === false || typeof urlValue !== 'string') return;
this.handleCrossOriginNavigationRequest(record, urlValue, eventValue);
});
this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => {
const previousUrl = record.url;
if (typeof urlValue === 'string') {
record.url = urlValue;
if (this.isPreviewDataCrossOrigin(record, urlValue)) {
this.invalidatePreviewData(record, 'cross_origin_navigation');
}
if (crossOriginNavigation(previousUrl, urlValue)) {
this.notifyLifecycle({
type: 'cross-origin-navigation',
@@ -792,6 +1060,9 @@ export class AgentBrowserModule {
const previousUrl = record.url;
if (typeof urlValue === 'string' && isMainFrameValue !== false) {
record.url = urlValue;
if (this.isPreviewDataCrossOrigin(record, urlValue)) {
this.invalidatePreviewData(record, 'cross_origin_navigation');
}
if (crossOriginNavigation(previousUrl, urlValue)) {
this.notifyLifecycle({
type: 'cross-origin-navigation',
@@ -845,6 +1116,7 @@ export class AgentBrowserModule {
});
this.addWebContentsListener(record, 'render-process-gone', () => {
if (record !== this.record || record.state === 'closing') return;
this.invalidatePreviewData(record, 'browser_crashed');
record.state = 'crashed';
record.error = {
code: 'RENDERER_CRASHED',
@@ -864,6 +1136,7 @@ export class AgentBrowserModule {
});
this.addWebContentsListener(record, 'destroyed', () => {
if (record !== this.record || record.state === 'closing') return;
this.invalidatePreviewData(record, 'browser_crashed');
record.state = 'crashed';
record.error = {
code: 'TARGET_GONE',
@@ -949,11 +1222,63 @@ export class AgentBrowserModule {
if (!isRecord(params)) return;
if (method === 'Target.attachedToTarget' && typeof params.sessionId === 'string') {
record.childSessions.add(params.sessionId);
const previewBinding = record.previewData;
if (previewBinding && !previewBinding.invalidated && isDocumentTarget(params)) {
void this.installPreviewData(record, params.sessionId)
.then(async () => {
if (params.waitingForDebugger !== true
|| record !== this.record
|| record.view.webContents.isDestroyed()) return;
// If invalidation won the race with script registration, wait for
// removal before releasing the paused child target. It must never
// execute with a token after the session has been invalidated.
await previewBinding.cleanup;
if (record !== this.record || record.view.webContents.isDestroyed()) return;
if (record.previewData?.invalidated) return;
await record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
params.sessionId as string,
);
})
.catch(async () => {
await this.invalidatePreviewData(record, 'manual');
if (
params.waitingForDebugger === true
&& record === this.record
&& !record.view.webContents.isDestroyed()
) {
await record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
params.sessionId as string,
).catch(() => undefined);
}
});
} else if (params.waitingForDebugger === true) {
void record.view.webContents.debugger.sendCommand(
'Runtime.runIfWaitingForDebugger',
undefined,
params.sessionId,
).catch(() => undefined);
}
} else if (
method === 'Target.detachedFromTarget'
&& typeof params.sessionId === 'string'
) {
record.childSessions.delete(params.sessionId);
const binding = record.previewData;
if (binding) {
const detachedScripts = binding.scripts.filter((script) => script.sessionRef === params.sessionId);
binding.scripts = binding.scripts.filter((script) => script.sessionRef !== params.sessionId);
for (const script of detachedScripts) {
void record.view.webContents.debugger.sendCommand(
'Page.removeScriptToEvaluateOnNewDocument',
{ identifier: script.identifier },
script.sessionRef,
).catch(() => undefined);
}
}
}
}
@@ -992,6 +1317,9 @@ export class AgentBrowserModule {
}
private async navigateTo(record: BrowserRecord, targetUrl: string): Promise<void> {
if (this.isPreviewDataCrossOrigin(record, targetUrl)) {
await this.invalidatePreviewData(record, 'cross_origin_navigation');
}
try {
await this.loadPageUntilReady(record, targetUrl);
record.url = targetUrl;
@@ -1216,6 +1544,7 @@ export class AgentBrowserModule {
}
if (expected && this.record !== expected) return;
record.state = 'closing';
await this.invalidatePreviewData(record, 'preview_closed');
this.notifyLifecycle({
type: 'closed',
projectId: record.projectId,
@@ -1566,6 +1895,66 @@ function normalizeUrl(value: string): string {
return url.toString();
}
function assertPreviewDataTarget(value: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new AgentBrowserFault('TARGET_DENIED', '数据运行时只允许打开本机预览地址。', false);
}
const hostname = url.hostname.toLowerCase();
const loopback = hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '[::1]';
if (
!loopback
|| (url.protocol !== 'http:' && url.protocol !== 'https:')
|| url.username
|| url.password
) {
throw new AgentBrowserFault('TARGET_DENIED', '数据运行时只允许打开本机预览地址。', false);
}
}
function sameOrigin(left: string, right: string): boolean {
try {
return new URL(left).origin === new URL(right).origin;
} catch {
return false;
}
}
function preventableEvent(value: unknown): { preventDefault(): void } | undefined {
return isRecord(value) && typeof value.preventDefault === 'function'
? value as { preventDefault(): void }
: undefined;
}
function isDocumentTarget(params: Record<string, unknown>): boolean {
const targetInfo = isRecord(params.targetInfo) ? params.targetInfo : undefined;
const type = targetInfo?.type;
return type === 'page' || type === 'iframe' || type === 'webview';
}
function previewDataInjectionScript(
origin: string,
value: PreviewDataInjectionValue,
): string {
const serializedOrigin = JSON.stringify(origin);
const serializedValue = JSON.stringify(value);
if (!serializedOrigin || !serializedValue) {
throw new Error('Preview data injection value is not serializable');
}
return `if (globalThis.location?.origin === ${serializedOrigin}) {
Object.defineProperty(globalThis, "__MAKELORE_DATA__", {
value: ${serializedValue},
enumerable: false,
writable: false,
configurable: false,
});
}`;
}
function crossOriginNavigation(previousUrl: string, nextUrl: string): boolean {
if (previousUrl === 'about:blank' || nextUrl === 'about:blank') return false;
try {