Files
rea/src/browser/CdpCaptureEventBodyShapes.ts
T
morluto d23dc5d188 fix(browser): admit JSON shape expansion against shared retention
Require explicit shape output admission before allocating expanded paths. Share capture retention across console entries, WebSocket frames and selected request/response shapes, with exact shape replacement and removal receipts.

Preserve malformed-input versus capacity outcomes and peer facts. Validated deep, wide, Unicode, repeated-row, replacement, redirect, reset and mixed-retention regressions.
2026-10-25 09:49:26 +08:00

146 lines
4.8 KiB
TypeScript

import { inferJsonShape, type AdmittedJsonShape } from "../domain/jsonShape.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import { decodePaddedBase64 } from "../domain/paddedBase64.js";
import {
recordValue,
cdpStringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { isJsonContentType } from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js";
import type { NetworkState } from "./CdpCaptureEventTypes.js";
export const ingestResponseBodyShape = (
state: CdpCaptureEventsState,
requestId: string,
value: unknown,
): void => {
if (!state.network.has(requestId)) return;
const result = recordValue(value);
const body = cdpStringValue(result?.body);
const base64Encoded = result?.base64Encoded;
if (body === undefined || typeof base64Encoded !== "boolean") {
invalidResponseBodyShape(state, requestId);
return;
}
const decoded = base64Encoded ? decodePaddedBase64(body) : body;
if (decoded === undefined) {
invalidResponseBodyShape(state, requestId);
return;
}
const previousBytes =
state.bodyShapeBytesByRequestId.get(requestId)?.response ?? 0;
const parsed = inferJsonShape(
decoded,
WEB_RUNTIME_LIMITS.retainedEventBytes -
state.retainedEventBytes +
previousBytes,
);
if (!parsed.ok) {
if (parsed.error.kind === "capacity")
state.completeness.exclude(
"json_body_shapes",
"resource_budget_exhausted",
);
else state.completeness.unavailable("json_body_shapes");
}
updateResponseBodyShape(state, requestId, parsed.ok ? parsed.value : null);
};
export const requestBodyShape = (
state: CdpCaptureEventsState,
requestId: string,
request: UnknownRecord,
): NetworkState["body_shapes"] => {
releaseBodyShapes(state, requestId);
if (!state.input.include_json_body_shapes)
return { status: "not_approved", request: null, response: null };
const headers = recordValue(request.headers);
const json = isJsonContentType(headers);
const body = cdpStringValue(request.postData);
// CDP may omit a long body from postData while reporting hasPostData.
const parsed =
json && body !== undefined
? inferJsonShape(
body,
WEB_RUNTIME_LIMITS.retainedEventBytes - state.retainedEventBytes,
)
: undefined;
const shape = parsed?.ok ? parsed.value.shape : null;
const expected = json && (body !== undefined || request.hasPostData === true);
if (expected && shape === null) {
state.missingRequestBodyShapeIds.add(requestId);
if (parsed !== undefined && !parsed.ok && parsed.error.kind === "capacity")
state.completeness.exclude(
"json_body_shapes",
"resource_budget_exhausted",
);
else state.completeness.unavailable("json_body_shapes");
}
if (parsed?.ok) {
state.bodyShapeBytesByRequestId.set(requestId, {
request: parsed.value.serializedBytes,
response: 0,
});
state.retainedEventBytes += parsed.value.serializedBytes;
}
return {
status: shape === null ? "unavailable" : "included",
request: shape,
response: null,
};
};
export const updateResponseBodyShape = (
state: CdpCaptureEventsState,
requestId: string,
response: AdmittedJsonShape | null,
): void => {
const current = state.network.get(requestId);
if (current === undefined) return;
const previous = state.bodyShapeBytesByRequestId.get(requestId);
const responseBytes = response?.serializedBytes ?? 0;
const requestBytes = previous?.request ?? 0;
state.retainedEventBytes += responseBytes - (previous?.response ?? 0);
if (requestBytes + responseBytes === 0)
state.bodyShapeBytesByRequestId.delete(requestId);
else
state.bodyShapeBytesByRequestId.set(requestId, {
request: requestBytes,
response: responseBytes,
});
const request = current.body_shapes.request;
const status =
response !== null
? state.missingRequestBodyShapeIds.has(requestId)
? "partial"
: "included"
: request !== null
? "partial"
: "unavailable";
state.network.set(requestId, {
...current,
body_shapes: { status, request, response: response?.shape ?? null },
});
};
/** Replacing or excluding a network record retires exactly the shapes it retained. */
export const releaseBodyShapes = (
state: CdpCaptureEventsState,
requestId: string,
): void => {
state.missingRequestBodyShapeIds.delete(requestId);
const previous = state.bodyShapeBytesByRequestId.get(requestId);
if (previous === undefined) return;
state.retainedEventBytes -= previous.request + previous.response;
state.bodyShapeBytesByRequestId.delete(requestId);
};
const invalidResponseBodyShape = (
state: CdpCaptureEventsState,
requestId: string,
): void => {
updateResponseBodyShape(state, requestId, null);
state.completeness.exclude("json_body_shapes", "invalid_protocol_value");
};