mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 23:06:18 +00:00
* fix(browser): name web-bundle budgets and recover web deadlines Name the existing heap, per-script, and total web-bundle phases, and reject a per-script deadline above the total. Promote capture, CDP command, source-map decode, and source-map fetch deadlines at today's defaults. Name DevTools timeoutMs, and state that discovery, cleanup grace, byte budgets, and Electron hook retention have no runtime override. Refs #5910 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): apply selected web deadlines to the owning phase Keep HTTP and browser-socket discovery on the fixed discovery deadline so a short command_timeout_ms starts at the handshake. Apply scenario command_timeout_ms to launch startup and the primary connect session, and keep service-worker restoration on the fixed 5000 ms grace. Omit command_timeout_ms from recovery text when the operation does not accept it. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): retain scenario commands that finish after their deadline A timed-out scenario command stays owned so cleanup can close a late browser or context and restore a mutation that lands after the deadline. Child storage replies use that deadline, scenario recovery names command_timeout_ms in the capture JSON, and the session test connection implements beginCleanupGrace. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): keep child command timeouts as the public failure A nested command such as Page.enable that is delivered but never answered was stored as protocol_error, hiding the command name, selected deadline, and recovery. Store the typed timeout and assert that public error. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): keep runtime cleanup on the fixed grace Switch runtime restoration to the cleanup grace before the first send so a short command_timeout_ms cannot fail an otherwise successful observation. Stop advertising command_timeout_ms on list_browser_targets, which never bounds a post-discovery command. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(browser): prove a short command deadline cannot fail runtime cleanup Drive restoration through the runtime fixture. A cleanup command that outlives command_timeout_ms still succeeds, and a stalled restoration names the fixed 5000 ms grace. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the effective source-map decode deadline codecBudgetLimitation reported a fixed 20-second deadline after a caller selected decode_timeout_ms. Result and Evidence limitations now name that effective millisecond deadline. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): separate handshake and target-attach deadlines A browser-level Target.attachToTarget no longer shares the WebSocket handshake budget. Blocked service-worker attachment keeps discovery on its fixed deadline and applies command_timeout_ms to the later handshake and attach. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the web-bundle resource each runtime exhausts Bun ignores the V8 old-space flag. A Windows Job reports the process commit limit, and other Bun hosts no longer suggest raising max_heap_mb. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): cite the browser-socket discovery message budget WebSocket discovery enforces the 64 MiB protocol budget. Timeout recovery no longer advertises the 8 MiB HTTP document budget. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): bound the blocked service-worker check before discovery The first registration check has its own fixed 10s deadline. Discovery, handshake, and Target.attachToTarget stay on their separate deadlines. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the operand that stopped web-bundle admission Host heap pressure no longer claims the fixed result budget is exhausted. A Windows Bun allocation before the commit limit still names that limit. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
import { request, type ClientRequest, type IncomingMessage } from "node:http";
|
|
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
|
cdpCommandTimeoutUnrelatedControl,
|
|
noRuntimeOverrideSentence,
|
|
} from "../domain/browserDeadline.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
|
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
|
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
|
import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
|
|
import { safeParseJson } from "../domain/safeJson.js";
|
|
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
|
import type { CdpConnection } from "./CdpConnection.js";
|
|
|
|
const endpointVersionSchema = z.object({
|
|
Browser: z.string().min(1),
|
|
"Protocol-Version": z.string().min(1),
|
|
"User-Agent": z.string(),
|
|
"V8-Version": z.string(),
|
|
"WebKit-Version": z.string(),
|
|
webSocketDebuggerUrl: z.string().min(1),
|
|
});
|
|
|
|
const endpointTargetSchema = z.object({
|
|
id: z.string().min(1),
|
|
type: z.string().min(1),
|
|
title: z.string().default(""),
|
|
url: z.string(),
|
|
attached: z.boolean().nullable().optional(),
|
|
webSocketDebuggerUrl: z.string().min(1).optional(),
|
|
});
|
|
const endpointTargetsSchema = z.array(endpointTargetSchema);
|
|
|
|
/** Per-response bounds for loopback CDP discovery documents. There is no runtime override. */
|
|
const CDP_DISCOVERY_RESPONSE_LIMITS = {
|
|
bytes: 8 * 1024 * 1024,
|
|
timeoutMs: CDP_DISCOVERY_RESPONSE_TIMEOUT_MS,
|
|
} as const;
|
|
|
|
const discoveryBudget = (phase: "response" | "browser-socket"): string =>
|
|
phase === "response"
|
|
? `The ${String(CDP_DISCOVERY_RESPONSE_LIMITS.bytes)}-byte discovery document budget`
|
|
: `The ${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`;
|
|
|
|
const discoveryTimeout = (
|
|
operation: BrowserObservationOperation,
|
|
timeoutMs: number,
|
|
phase: "response" | "browser-socket",
|
|
): BrowserObservationError => {
|
|
const subject =
|
|
phase === "response"
|
|
? `CDP discovery response exceeded its ${String(timeoutMs)} ms deadline.`
|
|
: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`;
|
|
const fixed = noRuntimeOverrideSentence(
|
|
`${discoveryBudget(phase)} and the ${String(timeoutMs)} ms discovery deadline stay fixed.`,
|
|
cdpCommandTimeoutUnrelatedControl(operation),
|
|
);
|
|
return new BrowserObservationError(operation, "timeout", {
|
|
detail: `${subject} ${fixed}`,
|
|
remediationAction: fixed,
|
|
});
|
|
};
|
|
|
|
interface CdpDiscoveryResponseLimits {
|
|
readonly bytes: number;
|
|
readonly timeoutMs: number;
|
|
}
|
|
|
|
const browserSocketVersionSchema = z.object({
|
|
protocolVersion: z.string().min(1),
|
|
product: z.string().min(1),
|
|
revision: z.string(),
|
|
userAgent: z.string(),
|
|
jsVersion: z.string(),
|
|
});
|
|
|
|
const browserSocketTargetsSchema = z.object({
|
|
targetInfos: z.array(
|
|
z.object({
|
|
targetId: z.string().min(1),
|
|
type: z.string().min(1),
|
|
title: z.string().default(""),
|
|
url: z.string(),
|
|
attached: z.boolean().optional(),
|
|
}),
|
|
),
|
|
});
|
|
|
|
/** 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 | null;
|
|
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 CDP discovery endpoints without following redirects. */
|
|
export const discoverCdpEndpoint = async (
|
|
endpoint: string,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
): Promise<CdpEndpointDiscovery> => {
|
|
const versionInput = await readCdpJson(
|
|
new URL("/json/version", endpoint),
|
|
operation,
|
|
signal,
|
|
);
|
|
const targetsInput = await readCdpJson(
|
|
new URL("/json/list", endpoint),
|
|
operation,
|
|
signal,
|
|
);
|
|
const version = parseCdpEndpointValue(
|
|
endpointVersionSchema,
|
|
versionInput,
|
|
operation,
|
|
);
|
|
const targets = parseCdpEndpointValue(
|
|
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, target.url),
|
|
url: target.url,
|
|
attached: target.attached ?? null,
|
|
...targetWebSocket(endpoint, target, operation),
|
|
})),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Run browser-socket discovery work, including its WebSocket handshake, under
|
|
* the HTTP discovery deadline. Reaching the deadline is a timeout, not a
|
|
* caller cancellation, so the internal abort is not carried as its cause.
|
|
*/
|
|
export const withCdpDiscoveryDeadline = async <T>(
|
|
operation: BrowserObservationOperation,
|
|
signal: AbortSignal | undefined,
|
|
work: (deadlineSignal: AbortSignal) => Promise<T>,
|
|
timeoutMs: number = CDP_DISCOVERY_RESPONSE_LIMITS.timeoutMs,
|
|
): Promise<T> => {
|
|
const deadline = new AbortController();
|
|
let abortSource: "caller" | "deadline" | undefined;
|
|
let abortReason: unknown;
|
|
const abort = (source: "caller" | "deadline", reason: unknown): void => {
|
|
if (abortSource !== undefined) return;
|
|
abortSource = source;
|
|
abortReason = reason;
|
|
deadline.abort(reason);
|
|
};
|
|
const onCallerAbort = (): void => {
|
|
if (signal?.aborted === true) abort("caller", signal.reason);
|
|
};
|
|
const timer = setTimeout(
|
|
() => abort("deadline", new Error("CDP discovery deadline exceeded")),
|
|
timeoutMs,
|
|
);
|
|
signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
onCallerAbort();
|
|
try {
|
|
return await work(deadline.signal);
|
|
} catch (cause: unknown) {
|
|
if (cause === abortReason) {
|
|
if (abortSource === "caller")
|
|
throw new AnalysisCancelledError(operation, { cause: abortReason });
|
|
if (abortSource === "deadline")
|
|
throw discoveryTimeout(operation, timeoutMs, "browser-socket");
|
|
}
|
|
// A concrete failure produced before cancellation remains authoritative.
|
|
if (cause instanceof AnalysisError) {
|
|
if (
|
|
abortSource === "deadline" &&
|
|
cause instanceof AnalysisCancelledError &&
|
|
cause.cause === abortReason
|
|
)
|
|
throw discoveryTimeout(operation, timeoutMs, "browser-socket");
|
|
throw cause;
|
|
}
|
|
throw cause;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener("abort", onCallerAbort);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Read discovery facts over a browser socket that has no HTTP discovery.
|
|
* Each reply is bounded by the connection's protocol message limit; callers
|
|
* apply the discovery deadline through `signal`.
|
|
*/
|
|
export const discoverCdpBrowserSocket = async (
|
|
connection: CdpConnection,
|
|
url: string,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
): Promise<CdpEndpointDiscovery> => {
|
|
const versionInput = await connection.send(
|
|
"Browser.getVersion",
|
|
{},
|
|
undefined,
|
|
signal,
|
|
);
|
|
const targetsInput = await connection.send(
|
|
"Target.getTargets",
|
|
{},
|
|
undefined,
|
|
signal,
|
|
);
|
|
const version = parseCdpEndpointValue(
|
|
browserSocketVersionSchema,
|
|
versionInput,
|
|
operation,
|
|
);
|
|
const targets = parseCdpEndpointValue(
|
|
browserSocketTargetsSchema,
|
|
targetsInput,
|
|
operation,
|
|
);
|
|
return {
|
|
webSocket: { scope: "browser", url },
|
|
version: {
|
|
product: version.product,
|
|
protocol_version: version.protocolVersion,
|
|
revision: version.revision,
|
|
user_agent: version.userAgent,
|
|
js_version: version.jsVersion,
|
|
},
|
|
targets: targets.targetInfos.map((target) => ({
|
|
id: target.targetId,
|
|
type: target.type,
|
|
title: sanitizeTargetTitle(target.title, target.url),
|
|
url: target.url,
|
|
attached: target.attached ?? null,
|
|
})),
|
|
};
|
|
};
|
|
|
|
const sanitizeTargetTitle = (value: string, targetUrl: string): string => {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch (cause: unknown) {
|
|
// Non-URL titles fall back to alias redaction.
|
|
void cause;
|
|
return sanitizeTargetUrlAlias(value, targetUrl);
|
|
}
|
|
return url.protocol === "http:" || url.protocol === "https:"
|
|
? sanitizeBrowserUrl(value).url
|
|
: sanitizeTargetUrlAlias(value, targetUrl);
|
|
};
|
|
|
|
const sanitizeTargetUrlAlias = (value: string, targetUrl: string): string => {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(targetUrl);
|
|
} catch (cause: unknown) {
|
|
// Without a parseable target URL there is nothing to redact.
|
|
void cause;
|
|
return value;
|
|
}
|
|
const markers = [parsed.origin, `//${parsed.host}`, parsed.host];
|
|
if (parsed.pathname !== "/") markers.push(parsed.pathname);
|
|
const candidatePattern = new RegExp(
|
|
`(?:${markers.map(escapeRegExp).join("|")})[^\\s<>"']*`,
|
|
"gu",
|
|
);
|
|
return value.replace(candidatePattern, (candidate) =>
|
|
sanitizeSameOriginTitleUrl(candidate, parsed),
|
|
);
|
|
};
|
|
|
|
const sanitizeSameOriginTitleUrl = (candidate: string, target: URL): string => {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = candidate.startsWith("//")
|
|
? new URL(`${target.protocol}${candidate}`)
|
|
: candidate.startsWith(target.host)
|
|
? new URL(`${target.protocol}//${candidate}`)
|
|
: new URL(candidate, target.origin);
|
|
} catch (cause: unknown) {
|
|
// Unparseable candidates are preserved verbatim.
|
|
void cause;
|
|
return candidate;
|
|
}
|
|
return parsed.origin === target.origin &&
|
|
(parsed.username !== "" || parsed.password !== "")
|
|
? sanitizeBrowserUrl(candidate).url
|
|
: candidate;
|
|
};
|
|
|
|
const escapeRegExp = (value: string): string =>
|
|
value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
|
|
/** 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;
|
|
};
|
|
|
|
/** Parse one CDP discovery value through its exact boundary schema. */
|
|
export const parseCdpEndpointValue = <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 !== "" ||
|
|
urlDeclaresQueryOrFragment(parsed) ||
|
|
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/");
|
|
if (browserId !== undefined) return { scope: "browser" };
|
|
const targetId = pathIdentifier(pathname, "/devtools/page/");
|
|
return targetId === undefined ? undefined : { scope: "page", targetId };
|
|
};
|
|
|
|
const pathIdentifier = (
|
|
pathname: string,
|
|
prefix: string,
|
|
): string | undefined => {
|
|
if (!pathname.startsWith(prefix)) return undefined;
|
|
const identifier = pathname.slice(prefix.length);
|
|
return identifier.length > 0 && !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 (cause: unknown) {
|
|
// Endpoint validation failures mean no validated transport exists.
|
|
void cause;
|
|
return {};
|
|
}
|
|
};
|
|
|
|
/** Read one loopback CDP discovery document without redirects. */
|
|
export const readCdpJson = async (
|
|
url: URL,
|
|
operation: BrowserObservationOperation,
|
|
signal?: AbortSignal,
|
|
limits: CdpDiscoveryResponseLimits = CDP_DISCOVERY_RESPONSE_LIMITS,
|
|
): Promise<unknown> =>
|
|
await new Promise((resolve, reject) => {
|
|
if (signal?.aborted === true) {
|
|
reject(new AnalysisCancelledError(operation, { cause: signal.reason }));
|
|
return;
|
|
}
|
|
let response: IncomingMessage | undefined;
|
|
let request_: ClientRequest | undefined;
|
|
let timer: NodeJS.Timeout | undefined;
|
|
let settled = false;
|
|
const removeAbort = (): void =>
|
|
signal?.removeEventListener("abort", onAbort);
|
|
const finish = (error?: Error, value?: unknown): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
removeAbort();
|
|
if (error !== undefined) {
|
|
reject(error);
|
|
response?.destroy();
|
|
request_?.destroy();
|
|
} else resolve(value);
|
|
};
|
|
const onAbort = (): void => {
|
|
if (signal?.aborted === true)
|
|
finish(new AnalysisCancelledError(operation, { cause: signal.reason }));
|
|
};
|
|
request_ = request(
|
|
url,
|
|
{ method: "GET", headers: { Accept: "application/json" } },
|
|
(received) => {
|
|
response = received;
|
|
if (settled) {
|
|
received.destroy();
|
|
return;
|
|
}
|
|
if (received.statusCode !== 200) {
|
|
finish(
|
|
new BrowserObservationError(operation, "invalid_endpoint_response"),
|
|
);
|
|
return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
let bytes = 0;
|
|
received.on("data", (chunk: Buffer) => {
|
|
if (settled) return;
|
|
bytes += chunk.byteLength;
|
|
if (bytes > limits.bytes) {
|
|
finish(
|
|
new BrowserObservationError(operation, "payload_limit", {
|
|
detail: noRuntimeOverrideSentence(
|
|
`CDP discovery response exceeds its ${String(limits.bytes)}-byte limit.`,
|
|
cdpCommandTimeoutUnrelatedControl(operation),
|
|
),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
received.on("end", () => {
|
|
if (settled) return;
|
|
const parsed = safeParseJson(
|
|
Buffer.concat(chunks, bytes).toString("utf8"),
|
|
);
|
|
if (!parsed.ok) {
|
|
finish(
|
|
new BrowserObservationError(
|
|
operation,
|
|
"invalid_endpoint_response",
|
|
{ cause: parsed.cause },
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
finish(undefined, parsed.value);
|
|
});
|
|
received.on("aborted", () =>
|
|
finish(
|
|
new BrowserObservationError(operation, "invalid_endpoint_response"),
|
|
),
|
|
);
|
|
received.on("close", () => {
|
|
if (!settled && !received.complete)
|
|
finish(
|
|
new BrowserObservationError(
|
|
operation,
|
|
"invalid_endpoint_response",
|
|
),
|
|
);
|
|
});
|
|
received.on("error", (cause: unknown) =>
|
|
finish(endpointFailure(cause, operation, signal)),
|
|
);
|
|
},
|
|
);
|
|
request_.on("error", (cause: unknown) =>
|
|
finish(endpointFailure(cause, operation, signal)),
|
|
);
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
onAbort();
|
|
if (settled) return;
|
|
timer = setTimeout(
|
|
() => finish(discoveryTimeout(operation, limits.timeoutMs, "response")),
|
|
limits.timeoutMs,
|
|
);
|
|
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, { cause: signal.reason });
|
|
return new BrowserObservationError(operation, "endpoint_unreachable", {
|
|
cause,
|
|
});
|
|
};
|