mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
325 lines
9.4 KiB
TypeScript
325 lines
9.4 KiB
TypeScript
import { request } from "node:http";
|
|
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisError,
|
|
AnalysisTimeoutError,
|
|
BrowserObservationError,
|
|
type BrowserObservationOperation,
|
|
} from "../domain/errors.js";
|
|
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
|
|
|
const HTTP_TIMEOUT_MS = 5_000;
|
|
const MAX_VERSION_BYTES = 64 * 1_024;
|
|
const MAX_TARGET_LIST_BYTES = 2 * 1_024 * 1_024;
|
|
|
|
const endpointVersionSchema = z.object({
|
|
Browser: z.string().min(1).max(1_024),
|
|
"Protocol-Version": z.string().min(1).max(100),
|
|
"User-Agent": z.string().max(16_384),
|
|
"V8-Version": z.string().max(1_024),
|
|
"WebKit-Version": z.string().max(1_024),
|
|
webSocketDebuggerUrl: z.string().min(1).max(2_048),
|
|
});
|
|
|
|
const endpointTargetSchema = z.object({
|
|
id: z.string().min(1).max(256),
|
|
type: z.string().min(1).max(100),
|
|
title: z.string().max(16_384).default(""),
|
|
url: z.string().max(65_536),
|
|
attached: z.boolean().default(false),
|
|
webSocketDebuggerUrl: z.string().min(1).max(2_048).optional(),
|
|
});
|
|
const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000);
|
|
|
|
/** Validated direct CDP WebSocket bound to one discovered page target. */
|
|
interface CdpPageWebSocketEndpoint {
|
|
readonly scope: "page";
|
|
readonly targetId: string;
|
|
readonly url: string;
|
|
}
|
|
|
|
/** Validated CDP discovery socket and its command-routing scope. */
|
|
export type CdpWebSocketEndpoint =
|
|
| { readonly scope: "browser"; readonly url: string }
|
|
| CdpPageWebSocketEndpoint;
|
|
|
|
/** Bounded discovery target with an optional validated direct transport. */
|
|
export interface CdpEndpointTarget {
|
|
readonly id: string;
|
|
readonly type: string;
|
|
readonly title: string;
|
|
readonly url: string;
|
|
readonly attached: boolean;
|
|
readonly webSocket?: CdpPageWebSocketEndpoint;
|
|
}
|
|
|
|
export interface CdpEndpointDiscovery {
|
|
readonly webSocket: CdpWebSocketEndpoint;
|
|
readonly version: {
|
|
readonly product: string;
|
|
readonly protocol_version: string;
|
|
readonly revision: string;
|
|
readonly user_agent: string;
|
|
readonly js_version: string;
|
|
};
|
|
readonly targets: readonly CdpEndpointTarget[];
|
|
}
|
|
|
|
/** Read bounded CDP discovery endpoints without following redirects. */
|
|
export const discoverCdpEndpoint = async (
|
|
endpoint: string,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
): Promise<CdpEndpointDiscovery> => {
|
|
const versionInput = await readJson(
|
|
new URL("/json/version", endpoint),
|
|
MAX_VERSION_BYTES,
|
|
operation,
|
|
signal,
|
|
);
|
|
const targetsInput = await readJson(
|
|
new URL("/json/list", endpoint),
|
|
MAX_TARGET_LIST_BYTES,
|
|
operation,
|
|
signal,
|
|
);
|
|
const version = parseEndpointValue(
|
|
endpointVersionSchema,
|
|
versionInput,
|
|
operation,
|
|
);
|
|
const targets = parseEndpointValue(
|
|
endpointTargetsSchema,
|
|
targetsInput,
|
|
operation,
|
|
);
|
|
return {
|
|
webSocket: safeCdpWebSocketEndpoint(
|
|
endpoint,
|
|
version.webSocketDebuggerUrl,
|
|
operation,
|
|
),
|
|
version: {
|
|
product: version.Browser,
|
|
protocol_version: version["Protocol-Version"],
|
|
revision: version["WebKit-Version"],
|
|
user_agent: version["User-Agent"],
|
|
js_version: version["V8-Version"],
|
|
},
|
|
targets: targets.map((target) => ({
|
|
id: target.id,
|
|
type: target.type,
|
|
title: sanitizeTargetTitle(target.title),
|
|
url: target.url,
|
|
attached: target.attached,
|
|
...targetWebSocket(endpoint, target, operation),
|
|
})),
|
|
};
|
|
};
|
|
|
|
const sanitizeTargetTitle = (value: string): string => {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
return url.protocol === "http:" || url.protocol === "https:"
|
|
? sanitizeBrowserUrl(value).url.slice(0, 16_384)
|
|
: value;
|
|
};
|
|
|
|
/** Select a browser attachment socket or a direct socket for one page target. */
|
|
export const cdpTargetWebSocket = (
|
|
discovery: CdpEndpointDiscovery,
|
|
target: CdpEndpointTarget,
|
|
operation: BrowserObservationOperation,
|
|
): CdpWebSocketEndpoint => {
|
|
const webSocket = availableCdpTargetWebSocket(discovery, target);
|
|
if (webSocket !== undefined) return webSocket;
|
|
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
|
};
|
|
|
|
/** Report whether discovery contains a validated transport for one target. */
|
|
export const hasCdpTargetWebSocket = (
|
|
discovery: CdpEndpointDiscovery,
|
|
target: CdpEndpointTarget,
|
|
): boolean => availableCdpTargetWebSocket(discovery, target) !== undefined;
|
|
|
|
const availableCdpTargetWebSocket = (
|
|
discovery: CdpEndpointDiscovery,
|
|
target: CdpEndpointTarget,
|
|
): CdpWebSocketEndpoint | undefined => {
|
|
if (discovery.webSocket.scope === "browser") return discovery.webSocket;
|
|
if (target.webSocket !== undefined) return target.webSocket;
|
|
return discovery.webSocket.targetId === target.id
|
|
? discovery.webSocket
|
|
: undefined;
|
|
};
|
|
|
|
const parseEndpointValue = <Output>(
|
|
schema: z.ZodType<Output>,
|
|
input: unknown,
|
|
operation: BrowserObservationOperation,
|
|
): Output => {
|
|
const parsed = schema.safeParse(input);
|
|
if (!parsed.success)
|
|
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
|
|
cause: parsed.error,
|
|
});
|
|
return parsed.data;
|
|
};
|
|
|
|
const safeCdpWebSocketEndpoint = (
|
|
endpoint: string,
|
|
reported: string,
|
|
operation: BrowserObservationOperation,
|
|
): CdpWebSocketEndpoint => {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(reported);
|
|
} catch (cause: unknown) {
|
|
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
|
|
cause,
|
|
});
|
|
}
|
|
const trustedEndpoint = new URL(endpoint);
|
|
const path = cdpWebSocketPath(parsed.pathname);
|
|
if (
|
|
parsed.protocol !== "ws:" ||
|
|
parsed.port !== trustedEndpoint.port ||
|
|
parsed.username !== "" ||
|
|
parsed.password !== "" ||
|
|
parsed.search !== "" ||
|
|
parsed.hash !== "" ||
|
|
path === undefined
|
|
)
|
|
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
|
parsed.hostname = trustedEndpoint.hostname;
|
|
return path.scope === "browser"
|
|
? { scope: "browser", url: parsed.href }
|
|
: { scope: "page", targetId: path.targetId, url: parsed.href };
|
|
};
|
|
|
|
const cdpWebSocketPath = (
|
|
pathname: string,
|
|
):
|
|
| { readonly scope: "browser" }
|
|
| { readonly scope: "page"; targetId: string }
|
|
| undefined => {
|
|
const browserId = pathIdentifier(pathname, "/devtools/browser/", 1_024);
|
|
if (browserId !== undefined) return { scope: "browser" };
|
|
const targetId = pathIdentifier(pathname, "/devtools/page/", 256);
|
|
return targetId === undefined ? undefined : { scope: "page", targetId };
|
|
};
|
|
|
|
const pathIdentifier = (
|
|
pathname: string,
|
|
prefix: string,
|
|
maximumLength: number,
|
|
): string | undefined => {
|
|
if (!pathname.startsWith(prefix)) return undefined;
|
|
const identifier = pathname.slice(prefix.length);
|
|
return identifier.length > 0 &&
|
|
identifier.length <= maximumLength &&
|
|
!identifier.includes("/")
|
|
? identifier
|
|
: undefined;
|
|
};
|
|
|
|
const targetWebSocket = (
|
|
endpoint: string,
|
|
target: z.infer<typeof endpointTargetSchema>,
|
|
operation: BrowserObservationOperation,
|
|
): { readonly webSocket?: CdpPageWebSocketEndpoint } => {
|
|
if (target.webSocketDebuggerUrl === undefined) return {};
|
|
try {
|
|
const webSocket = safeCdpWebSocketEndpoint(
|
|
endpoint,
|
|
target.webSocketDebuggerUrl,
|
|
operation,
|
|
);
|
|
return webSocket.scope === "page" && webSocket.targetId === target.id
|
|
? { webSocket }
|
|
: {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const readJson = async (
|
|
url: URL,
|
|
maximumBytes: number,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
): Promise<unknown> =>
|
|
await new Promise((resolve, reject) => {
|
|
if (signal?.aborted === true) {
|
|
reject(new AnalysisCancelledError(operation));
|
|
return;
|
|
}
|
|
const request_ = request(
|
|
url,
|
|
{ method: "GET", signal, headers: { Accept: "application/json" } },
|
|
(response) => {
|
|
if (response.statusCode !== 200) {
|
|
response.resume();
|
|
reject(
|
|
new BrowserObservationError(operation, "invalid_endpoint_response"),
|
|
);
|
|
return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
let bytes = 0;
|
|
response.on("data", (chunk: Buffer) => {
|
|
bytes += chunk.length;
|
|
if (bytes > maximumBytes) {
|
|
response.destroy(
|
|
new BrowserObservationError(operation, "payload_limit"),
|
|
);
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
response.on("end", () => {
|
|
try {
|
|
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
} catch (cause: unknown) {
|
|
reject(
|
|
new BrowserObservationError(
|
|
operation,
|
|
"invalid_endpoint_response",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
});
|
|
response.on("error", (cause: unknown) =>
|
|
reject(endpointFailure(cause, operation, signal)),
|
|
);
|
|
},
|
|
);
|
|
request_.setTimeout(HTTP_TIMEOUT_MS, () =>
|
|
request_.destroy(new AnalysisTimeoutError(operation, HTTP_TIMEOUT_MS)),
|
|
);
|
|
request_.on("error", (cause: unknown) =>
|
|
reject(endpointFailure(cause, operation, signal)),
|
|
);
|
|
request_.end();
|
|
});
|
|
|
|
const endpointFailure = (
|
|
cause: unknown,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
): AnalysisError => {
|
|
if (cause instanceof AnalysisError) return cause;
|
|
if (signal?.aborted === true) return new AnalysisCancelledError(operation);
|
|
return new BrowserObservationError(operation, "endpoint_unreachable", {
|
|
cause,
|
|
});
|
|
};
|