Files
reverse-engineering-anything/src/browser/CdpBrowserProvider.ts
T
morluto 9aca543f46 fix(browser): isolate web bundle analysis with bounded partial results
Run inert captured-script analysis in owned children with per-script and aggregate deadlines, bounded source/result transfer and explicit per-script coverage. Preserve completed facts and exact cleanup authority across cancellation, mixed descriptor/process failures and cross-consumer retry.

Share aggregate source admission across page and Electron capture, expose CLI/MCP resource controls and retain selected parameters in Evidence. Verified with controlled CDP public workflows, real owned Node failure controls and the reporter-matching ReactDOM UMD artifact; real Chrome/Electron and Windows/Bun remain unqualified.
2026-10-19 13:22:54 +08:00

582 lines
18 KiB
TypeScript

import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
import type { ProviderIdentity } from "../application/AnalysisProvider.js";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import {
sanitizeBrowserUrl,
type InspectWebPageInput,
type ListBrowserTargetsInput,
} from "../domain/browserObservation.js";
import {
browserTargetListSchema,
webPageInspectionSchema,
type BrowserTargetList,
type WebPageInspection,
} from "../domain/browserObservationSchemas.js";
import { IsolatedWebBundleAnalyzer } from "./IsolatedWebBundleAnalyzer.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import {
webObservationSessionSchema,
type ObserveWebSessionInput,
type WebObservationSession,
} from "../domain/browserSession.js";
import {
webMcpDiscoverySchema,
type DiscoverWebMcpToolsInput,
type WebMcpDiscovery,
} from "../domain/webMcpDiscovery.js";
import {
browserCaptureComparisonSchema,
compareBrowserCaptures,
type BrowserCaptureComparison,
type BrowserCaptureComparisonInput,
} from "../domain/browserCaptureComparison.js";
import {
webScreenshotDiffSchema,
webScreenshotSchema,
type CaptureWebScreenshotInput,
type CompareWebScreenshotsInput,
type WebScreenshot,
type WebScreenshotDiff,
} from "../domain/webScreenshot.js";
import {
AnalysisError,
type AnalysisPartialObservation,
} from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
hasCdpTargetWebSocket,
type CdpEndpointDiscovery,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
import {
attachCdpTargetSession,
closeCdpTargetSession,
openCdpEndpoint,
openCdpTargetSession,
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
import { observeCdpSession } from "./CdpObservationSession.js";
import { discoverWebMcp } from "./CdpWebMcpDiscovery.js";
import { captureCdpScreenshot } from "./CdpScreenshot.js";
import { comparePngScreenshots } from "./PngVisualDiff.js";
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
CdpTargetSessionOwner,
bindCdpTransportCleanup,
} from "./CdpTargetSessionOwner.js";
const CLEANUP_DOMAINS = [
"Network",
"Debugger",
"Runtime",
"Page",
"WebMCP",
] as const;
/** Passive browser observation through a selected user-owned CDP endpoint. */
export class CdpBrowserProvider implements BrowserObservationPort {
readonly #owner = new CdpTargetSessionOwner();
readonly #bundleAnalyzer = new IsolatedWebBundleAnalyzer();
async close(): Promise<void> {
const outcomes = await Promise.allSettled([
this.#owner.close(),
this.#bundleAnalyzer.close(),
]);
const failures = outcomes.flatMap((outcome) =>
outcome.status === "rejected" ? [outcome.reason as unknown] : [],
);
if (failures.length === 1) throw failures[0];
if (failures.length > 1) {
const typed = failures.filter(
(failure): failure is AnalysisError => failure instanceof AnalysisError,
);
throw new ProviderCleanupError(
CDP_BROWSER_PROVIDER_IDENTITY.id,
typed.flatMap((failure) => failure.cleanupResources),
{ reason: "Browser transport and analyzer cleanup remain incomplete" },
{
operation: "analyze_web_bundle",
cause: new AggregateError(failures),
},
).bindCleanupDependencies(typed);
}
}
identity(): ProviderIdentity {
return CDP_BROWSER_PROVIDER_IDENTITY;
}
async listTargets(
input: ListBrowserTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<BrowserTargetList, AnalysisError>> {
try {
const endpoint = await openCdpEndpoint(
input.cdp_endpoint,
"list_browser_targets",
options.signal,
undefined,
this.#owner,
);
if (endpoint.connection !== undefined)
await this.#owner.finish(endpoint.connection);
const { discovery } = endpoint;
const selected = selectTargets(
discovery,
input.allowed_origins.length === 0
? undefined
: new Set(input.allowed_origins),
);
return ok(
browserTargetListSchema.parse({
browser: discovery.version,
targets: selected.allowed,
excluded: selected.excluded,
limitations: [
input.allowed_origins.length === 0
? "All page targets with supported HTTP(S) URLs are listed."
: "Only page targets whose current URL matches an explicitly requested exact origin are listed.",
...(selected.unconnectable === 0
? []
: [
`${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
]),
...(selected.allowed.some((target) => target.attached === null)
? [
"CDP discovery omitted attachment state for one or more targets; attached is reported as unknown.",
]
: []),
],
}),
);
} catch (cause: unknown) {
return err(providerError(cause, "list_browser_targets", options.signal));
}
}
async inspectPage(
input: InspectWebPageInput,
options: ExecutionOptions = {},
): Promise<Result<WebPageInspection, AnalysisError>> {
try {
const captured = await this.#capture(input, options, "inspect_web_page");
return ok(webPageInspectionSchema.parse(captured.inspection));
} catch (cause: unknown) {
return err(providerError(cause, "inspect_web_page", options.signal));
}
}
async analyzeBundle(
input: AnalyzeWebBundleInput,
options: ExecutionOptions = {},
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
const admission = await this.#bundleAnalyzer.reserve();
if (!admission.ok) return admission;
let captured: CapturedPage | undefined;
try {
captured = await this.#capture(input, options, "analyze_web_bundle");
const sourceMaps = input.fetch_source_maps
? await fetchWebSourceMaps(
captured.sourceMapRequests,
{
...input,
allowed_origins:
input.allowed_origins.length > 0
? input.allowed_origins
: [captured.inspection.target.origin],
},
options.signal,
)
: undefined;
return await this.#bundleAnalyzer.analyze(
captured.inspection,
sourceMaps ?? {
status: "not_requested",
requested: 0,
processed: 0,
items: [],
},
input,
options.signal,
);
} catch (cause: unknown) {
// fetchWebSourceMaps rethrows the exact abort reason. Preserve cancellation
// even when a caller supplied an AnalysisError as that reason.
if (options.signal?.aborted === true && cause === options.signal.reason) {
return err(
new AnalysisCancelledError("analyze_web_bundle", {
cause,
...(captured === undefined
? {}
: { partialObservation: captured.inspection }),
}),
);
}
return err(providerError(cause, "analyze_web_bundle", options.signal));
} finally {
this.#bundleAnalyzer.releaseReservation();
}
}
async observeSession(
input: ObserveWebSessionInput,
options: ExecutionOptions = {},
): Promise<Result<WebObservationSession, AnalysisError>> {
try {
return ok(
webObservationSessionSchema.parse(
await withTargetSession(
input,
"observe_web_session",
options,
observeCdpSession,
this.#owner,
),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "observe_web_session", options.signal));
}
}
async discoverWebMcpTools(
input: DiscoverWebMcpToolsInput,
options: ExecutionOptions = {},
): Promise<Result<WebMcpDiscovery, AnalysisError>> {
try {
return ok(
webMcpDiscoverySchema.parse(
await withTargetSession(
input,
"discover_webmcp_tools",
options,
discoverWebMcp,
this.#owner,
),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "discover_webmcp_tools", options.signal));
}
}
async compareCaptures(
input: BrowserCaptureComparisonInput,
): Promise<Result<BrowserCaptureComparison, AnalysisError>> {
try {
return ok(
browserCaptureComparisonSchema.parse(compareBrowserCaptures(input)),
);
} catch (cause: unknown) {
return err(providerError(cause, "compare_web_captures"));
}
}
async captureScreenshot(
input: CaptureWebScreenshotInput,
options: ExecutionOptions = {},
): Promise<Result<WebScreenshot, AnalysisError>> {
try {
return ok(
webScreenshotSchema.parse(
await withTargetSession(
input,
"capture_web_screenshot",
options,
captureCdpScreenshot,
this.#owner,
),
),
);
} catch (cause: unknown) {
return err(
providerError(cause, "capture_web_screenshot", options.signal),
);
}
}
async compareScreenshots(
input: CompareWebScreenshotsInput,
): Promise<Result<WebScreenshotDiff, AnalysisError>> {
try {
return ok(webScreenshotDiffSchema.parse(comparePngScreenshots(input)));
} catch (cause: unknown) {
if (cause instanceof TypeError)
return err(
new AnalysisInputError("compare_web_screenshots", { cause }, [
{ path: [], reason: "invalid_format", message: cause.message },
]),
);
return err(providerError(cause, "compare_web_screenshots"));
}
}
async #capture(
input: InspectWebPageInput,
options: ExecutionOptions,
operation: "inspect_web_page" | "analyze_web_bundle",
): Promise<CapturedPage> {
return await withTargetSession(
input,
operation,
options,
async (session) => await capturePage({ ...session, operation }),
this.#owner,
(captured) => captured.inspection,
);
}
}
/** Facts and transport handed to one target operation. */
interface CdpTargetOperationSession<Input> {
readonly connection: CdpTargetSession["connection"];
readonly sessionId: CdpTargetSession["sessionId"];
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: Input;
readonly signal?: AbortSignal;
readonly progress?: NonNullable<ExecutionOptions["progress"]>;
}
/** Authorize one discovered target, run work in its session, and close REA's socket. */
const withTargetSession = async <
Input extends Pick<
InspectWebPageInput,
"cdp_endpoint" | "target_id" | "allowed_origins"
>,
Output,
>(
input: Input,
operation: BrowserObservationOperation,
options: ExecutionOptions,
work: (session: CdpTargetOperationSession<Input>) => Promise<Output>,
owner: CdpTargetSessionOwner,
partialObservation?: (output: Output) => AnalysisPartialObservation,
): Promise<Output> => {
const endpoint = await openCdpEndpoint(
input.cdp_endpoint,
operation,
options.signal,
undefined,
owner,
);
let targetSession: CdpTargetSession | undefined;
let outcome: Result<Output, unknown>;
let cleanupFailure: AnalysisError | undefined;
try {
const target = authorizeTarget(endpoint.discovery, input);
const scopedInput = scopeToTargetOrigin(input, target);
targetSession =
endpoint.connection === undefined
? await openCdpTargetSession(
endpoint.discovery,
target,
operation,
options.signal,
undefined,
owner,
)
: await attachCdpTargetSession(
endpoint.connection,
target,
operation,
options.signal,
owner,
);
outcome = ok(
await work({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery: endpoint.discovery,
target,
input: scopedInput,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
);
} catch (cause: unknown) {
outcome = err(cause);
}
try {
if (targetSession !== undefined)
await closeCdpTargetSession(
targetSession,
CLEANUP_DOMAINS,
options.signal,
owner,
);
if (
endpoint.connection !== undefined &&
endpoint.connection !== targetSession?.connection
)
await owner.finish(endpoint.connection);
} catch (cause: unknown) {
cleanupFailure =
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
"rea-cdp-browser",
[input.target_id],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ operation, cause },
);
}
if (outcome.ok && options.signal?.aborted === true)
outcome = err(
partialObservation === undefined
? new AnalysisCancelledError(operation, {
cause: options.signal.reason,
})
: new AnalysisCancelledError(operation, {
cause: options.signal.reason,
partialObservation: partialObservation(outcome.value),
}),
);
if (cleanupFailure !== undefined) {
if (!outcome.ok && outcome.error instanceof AnalysisError)
throw bindCdpTransportCleanup(
cleanupFailure,
analysisErrorWithCleanupFailure(
outcome.error,
cleanupFailure,
operation,
),
);
if (!outcome.ok)
throw new AggregateError(
[outcome.error, cleanupFailure],
`CDP operation ${operation} failed and cleanup remains incomplete.`,
{ cause: outcome.error },
);
throw bindCdpTransportCleanup(
cleanupFailure,
new ProviderCleanupError(
"rea-cdp-browser",
cleanupFailure.cleanupResources,
{ cleanup_error: cleanupFailure.message },
{
operation,
cause: cleanupFailure,
...(partialObservation === undefined
? {}
: { partialObservation: partialObservation(outcome.value) }),
},
),
);
}
if (!outcome.ok) throw outcome.error;
return outcome.value;
};
const selectTargets = (
discovery: CdpEndpointDiscovery,
allowedOrigins: ReadonlySet<string> | undefined,
): {
readonly allowed: readonly BrowserTargetList["targets"][number][];
readonly excluded: BrowserTargetList["excluded"];
readonly unconnectable: number;
} => {
const allowed = [];
let disallowedOrigin = 0;
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
for (const target of discovery.targets) {
if (target.type !== "page") {
nonPage += 1;
continue;
}
const url = sanitizeBrowserUrl(target.url);
if (url.origin === null) {
unsupportedUrl += 1;
continue;
}
if (allowedOrigins !== undefined && !allowedOrigins.has(url.origin)) {
disallowedOrigin += 1;
continue;
}
if (!hasCdpTargetWebSocket(discovery, target)) {
unconnectable += 1;
continue;
}
allowed.push({
target_id: target.id,
type: target.type,
title: target.title,
url: url.url,
origin: url.origin,
attached: target.attached,
});
}
allowed.sort((left, right) =>
compareUnicodeCodePoints(left.target_id, right.target_id),
);
return {
allowed,
excluded: {
disallowed_origin: disallowedOrigin,
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable,
};
};
const authorizeTarget = (
discovery: CdpEndpointDiscovery,
input: Pick<InspectWebPageInput, "target_id" | "allowed_origins">,
): CdpEndpointTarget => {
return authorizeCdpTarget(discovery, input, "inspect_web_page").target;
};
const scopeToTargetOrigin = <T extends { allowed_origins: readonly string[] }>(
input: T,
target: CdpEndpointTarget,
): T => {
if (input.allowed_origins.length > 0) return input;
const origin = sanitizeBrowserUrl(target.url).origin;
if (origin === null)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return { ...input, allowed_origins: [origin] };
};
const providerError = (
cause: unknown,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): AnalysisError =>
cause instanceof BrowserObservationError && cause.operation !== operation
? new BrowserObservationError(operation, cause.reason, { cause })
: cause instanceof AnalysisError
? cause
: signal?.aborted === true
? new AnalysisCancelledError(operation, { cause })
: new ProviderAdapterError(
CDP_BROWSER_PROVIDER_IDENTITY.id,
operation,
{
cause,
},
);