Offload large network log body formatting to a web worker

This commit is contained in:
musistudio
2026-08-05 15:49:18 +08:00
parent 561c2d813c
commit b4afa55c49
7 changed files with 528 additions and 38 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
import { buildBrowserRenderer, buildMain, buildRenderer, buildRequestLogBodyWorker, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
const mode = process.argv.includes("--dev") ? "development" : "production";
@@ -14,6 +14,7 @@ await Promise.all([
buildMain({ mode }),
buildBrowserRenderer({ mode }),
buildRenderer({ mode }),
buildRequestLogBodyWorker({ mode }),
buildTrayRenderer({ mode }),
buildWebClientBridge({ mode }),
buildStyles({ minify: mode === "production" })
+15 -1
View File
@@ -17,6 +17,7 @@ import {
copyModelCatalog,
copyRendererHtml,
copyTrayRendererHtml,
createRequestLogBodyWorkerBuildOptions,
createBrowserRendererBuildOptions,
createCliBuildOptions,
createMainBuildOptions,
@@ -51,6 +52,7 @@ let queuedStyleBuildReason = null;
const ready = {
browser: false,
cli: false,
logWorker: false,
main: false,
renderer: false,
tray: false,
@@ -66,6 +68,7 @@ const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
const activeReadyNames = new Set([
...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
...(enabled.ui ? ["logWorker"] : []),
...(enabled.cli ? ["cli"] : []),
...(enabled.electron ? ["main"] : [])
]);
@@ -295,7 +298,7 @@ function pollSourceWatchTargets() {
}
function markReady(name, reason = `${name} esbuild completed`) {
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
if (name === "browser" || name === "cli" || name === "logWorker" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
ready[name] = true;
}
logDev(`build ready: ${reason}; ${readyState()}`);
@@ -509,6 +512,17 @@ if (enabled.ui) {
})
]
})
),
await esbuild.context(
createRequestLogBodyWorkerBuildOptions({
mode: "development",
plugins: [
watchPlugin("logWorker", (name) => {
syncUiRendererToRuntimeDists();
markReady(name);
})
]
})
)
);
}
+26
View File
@@ -66,6 +66,8 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray",
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
export const cssOutput = path.join(rendererAssetsDir, "main.css");
export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
export const requestLogBodyWorkerOutput = path.join(rendererAssetsDir, "log-body.worker.js");
export const requestLogBodyWorkerInput = path.join(rendererRoot, "pages", "home", "shared", "log-body.worker.ts");
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
export const localAgentAuthProviderHookInput = path.join(coreSourceRoot, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts");
export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts");
@@ -379,6 +381,26 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins
};
}
export function createRequestLogBodyWorkerBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
define: {
"process.env.NODE_ENV": JSON.stringify(mode)
},
entryPoints: [requestLogBodyWorkerInput],
format: "esm",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outfile: requestLogBodyWorkerOutput,
platform: "browser",
plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "chrome120"
};
}
export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
@@ -464,6 +486,10 @@ export async function buildWebClientBridge(options = {}) {
await esbuild.build(createWebClientBridgeBuildOptions(options));
}
export async function buildRequestLogBodyWorker(options = {}) {
await esbuild.build(createRequestLogBodyWorkerBuildOptions(options));
}
export function copyCliRuntimeToElectronDist() {
ensureDist();
const cliRuntime = path.join(cliMainOutDir, "cli.js");
@@ -4,9 +4,11 @@ import type { RequestRouteTrace, RequestRouteTraceChange, RequestRouteTraceHop }
import {
AnimatedIconSwap, Check, ChevronDown, ChevronLeft,
ChevronRight, clampNumber, clientInitial, cn, Copy, copyTextToClipboard,
Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, filterLogText, formatBytes, formatCompactNumber, formatDuration,
formatLogBodyView, formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost,
isJsonContainer, jsonChildPath, logRequestModel,
createLogBodyPreviewText, Database, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, formatBytes, formatCompactNumber, formatDuration,
formatLogDateTime, formatLogTokenSummary, formatNetworkRequestRaw, formatNetworkResponseRaw, formatRouteTracePath, formatUsdCost,
FormattedLogBody,
isJsonContainer, isLargeLogBody, jsonChildPath, logRequestModel,
LogBodyFormatMode, logBodyLargeTextThreshold, logBodyPreviewTextLimit, LogBodyWorkerResponse,
logResponseModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel,
networkExchangeMatchesQuery, networkHeaderRows, networkLifecycleLabel, networkQueryRows, networkRowId, networkSummaryRows,
Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus,
@@ -19,11 +21,10 @@ import { TooltipPortal } from "@/components/ui/tooltip";
type NetworkRequestTab = "body" | "header" | "query" | "raw" | "summary";
type NetworkResponseTab = "body" | "header" | "raw";
const logBodyViewCacheLimit = 12;
const logJsonAutoExpandEntryLimit = 60;
const logJsonContainerPreviewLimit = 80;
const logJsonAutoExpandTextLimit = 160 * 1024;
const logBodyViewCache = new Map<string, ReturnType<typeof formatLogBodyView>>();
const logBodyWorkerFilterDebounceMs = 180;
type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration";
type LogTableColumn = {
id: LogTableColumnId;
@@ -1496,6 +1497,247 @@ function LogStreamCell({ entry }: { entry: RequestLogEntry }) {
type LogPayloadTab = "body" | "header";
type LogBodyPanelView = FormattedLogBody & {
bodyKey: string;
error: string;
formattedTextLength: number;
large: boolean;
loading: boolean;
mode: LogBodyFormatMode;
preview: boolean;
query: string;
sourceSizeBytes: number;
visible: string;
};
function useLogBodyWorkerView(
body: RequestLogBody | undefined,
bodyKey: string,
mode: LogBodyFormatMode,
query: string
): LogBodyPanelView {
const debouncedQuery = useDebouncedValue(query, logBodyWorkerFilterDebounceMs);
const latestQueryRef = useRef(debouncedQuery);
const workerRef = useRef<Worker>();
const formatRequestIdRef = useRef(0);
const filterRequestIdRef = useRef(0);
const [bodyView, setBodyView] = useState<LogBodyPanelView>(() => createInitialLogBodyPanelView(body, bodyKey, mode, query));
useEffect(() => {
latestQueryRef.current = debouncedQuery;
}, [debouncedQuery]);
useEffect(() => {
const initial = createInitialLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current);
setBodyView(initial);
workerRef.current?.terminate();
workerRef.current = undefined;
if (isStaticLogBody(body)) {
setBodyView(createStaticLogBodyPanelView(body, bodyKey, mode, latestQueryRef.current));
return;
}
if (typeof Worker === "undefined") {
setBodyView({
...initial,
error: "Body formatter worker is unavailable.",
loading: false,
visible: initial.visible || "Body formatter worker is unavailable."
});
return;
}
const worker = createLogBodyFormatterWorker();
const formatRequestId = formatRequestIdRef.current + 1;
formatRequestIdRef.current = formatRequestId;
filterRequestIdRef.current += 1;
workerRef.current = worker;
worker.onmessage = (event: MessageEvent<LogBodyWorkerResponse>) => {
const response = event.data;
if (response.kind === "format-result") {
if (response.id !== formatRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
return;
}
setBodyView(logBodyPanelViewFromWorkerResult(response));
if (response.query !== latestQueryRef.current) {
postLogBodyFilter(worker, bodyKey, mode, latestQueryRef.current, filterRequestIdRef);
}
return;
}
if (response.kind === "filter-result") {
if (response.id !== filterRequestIdRef.current || response.bodyKey !== bodyKey || response.mode !== mode) {
return;
}
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
? { ...current, query: response.query, visible: response.visible }
: current);
return;
}
if (
(response.operation === "format" && response.id === formatRequestIdRef.current) ||
(response.operation === "filter" && response.id === filterRequestIdRef.current)
) {
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
? { ...current, error: response.message, loading: false, visible: current.visible || response.message }
: current);
}
};
worker.onerror = (event) => {
setBodyView((current) => current.bodyKey === bodyKey && current.mode === mode
? { ...current, error: event.message || "Body formatter worker failed.", loading: false }
: current);
};
worker.postMessage({
body,
bodyKey,
id: formatRequestId,
kind: "format",
largeTextThreshold: logBodyLargeTextThreshold,
mode,
previewTextLimit: logBodyPreviewTextLimit,
query: latestQueryRef.current
});
return () => {
if (workerRef.current === worker) {
workerRef.current = undefined;
}
worker.terminate();
};
}, [body, bodyKey, mode]);
useEffect(() => {
const worker = workerRef.current;
if (!worker || bodyView.loading || bodyView.bodyKey !== bodyKey || bodyView.mode !== mode || bodyView.query === debouncedQuery) {
return;
}
postLogBodyFilter(worker, bodyKey, mode, debouncedQuery, filterRequestIdRef);
}, [bodyKey, bodyView.bodyKey, bodyView.loading, bodyView.mode, bodyView.query, debouncedQuery, mode]);
return bodyView;
}
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [delayMs, value]);
return debounced;
}
function createLogBodyFormatterWorker(): Worker {
return new Worker(new URL("../../assets/log-body.worker.js", window.location.href), { type: "module" });
}
function postLogBodyFilter(
worker: Worker,
bodyKey: string,
mode: LogBodyFormatMode,
query: string,
idRef: { current: number }
) {
const id = idRef.current + 1;
idRef.current = id;
worker.postMessage({
bodyKey,
id,
kind: "filter",
mode,
query
});
}
function createInitialLogBodyPanelView(
body: RequestLogBody | undefined,
bodyKey: string,
mode: LogBodyFormatMode,
query: string
): LogBodyPanelView {
if (isStaticLogBody(body)) {
return createStaticLogBodyPanelView(body, bodyKey, mode, query);
}
const large = isLargeLogBody(body, logBodyLargeTextThreshold);
const preview = large && mode !== "full";
const text = preview
? createLogBodyPreviewText(body, logBodyPreviewTextLimit)
: "Loading body...";
return {
bodyKey,
error: "",
formattedTextLength: text.length,
large,
loading: true,
mode,
preview,
query,
sourceSizeBytes: body?.sizeBytes ?? 0,
text,
visible: text
};
}
function createStaticLogBodyPanelView(
body: RequestLogBody | undefined,
bodyKey: string,
mode: LogBodyFormatMode,
query: string
): LogBodyPanelView {
const text = body?.text || "No body";
return {
bodyKey,
error: "",
formattedTextLength: text.length,
large: false,
loading: false,
mode,
preview: false,
query,
sourceSizeBytes: body?.sizeBytes ?? 0,
text,
visible: filterStaticLogBodyText(text, query)
};
}
function logBodyPanelViewFromWorkerResult(result: Extract<LogBodyWorkerResponse, { kind: "format-result" }>): LogBodyPanelView {
return {
bodyKey: result.bodyKey,
error: "",
formattedTextLength: result.formattedTextLength,
json: result.json,
large: result.large,
loading: false,
mode: result.mode,
preview: result.preview,
query: result.query,
sourceSizeBytes: result.sourceSizeBytes,
text: result.text,
visible: result.visible
};
}
function isStaticLogBody(body: RequestLogBody | undefined): boolean {
return !body || (!body.text && body.sizeBytes === 0);
}
function filterStaticLogBodyText(text: string, query: string): string {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return text;
}
return text.toLowerCase().includes(normalized) ? text : "No matching lines";
}
function LogJsonPanel({
body,
className,
@@ -1514,20 +1756,25 @@ function LogJsonPanel({
const t = useAppText();
const [selectedTab, setSelectedTab] = useState<LogPayloadTab>("body");
const [preferTextBody, setPreferTextBody] = useState(false);
const [bodyMode, setBodyMode] = useState<LogBodyFormatMode>("preview");
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [query, setQuery] = useState("");
const bodyKey = logBodyCacheKey(body);
const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]);
const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query);
const formatted = bodyView.text;
const visible = useMemo(() => filterLogText(formatted, query), [formatted, query]);
const visible = bodyView.visible;
const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]);
const [expandedJsonPaths, setExpandedJsonPaths] = useState<Set<string>>(() => createInitialVisibleJsonPaths(bodyView));
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody;
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview;
useEffect(() => {
setPreferTextBody(false);
setBodyMode("preview");
}, [bodyKey]);
useEffect(() => {
setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView));
setPreferTextBody(false);
}, [bodyKey]);
}, [bodyView.bodyKey, bodyView.json, bodyView.text]);
useEffect(() => {
if (!fullscreenOpen) {
@@ -1582,6 +1829,7 @@ function LogJsonPanel({
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onLoadFullBody={() => setBodyMode("full")}
onQueryChange={setQuery}
onToggleTextBody={() => setPreferTextBody((current) => !current)}
preferTextBody={preferTextBody}
@@ -1610,6 +1858,7 @@ function LogJsonPanel({
copyText={formatted}
expandedJsonPaths={expandedJsonPaths}
onClose={() => setFullscreenOpen(false)}
onLoadFullBody={() => setBodyMode("full")}
onQueryChange={setQuery}
onToggleJsonPath={toggleJsonPath}
onToggleTextBody={() => setPreferTextBody((current) => !current)}
@@ -1636,6 +1885,7 @@ function LogJsonPanel({
function LogJsonBodyToolbar({
body,
bodyView,
onLoadFullBody,
onQueryChange,
onToggleTextBody,
preferTextBody,
@@ -1643,7 +1893,8 @@ function LogJsonBodyToolbar({
title
}: {
body?: RequestLogBody;
bodyView: ReturnType<typeof formatLogBodyView>;
bodyView: LogBodyPanelView;
onLoadFullBody: () => void;
onQueryChange: (value: string) => void;
onToggleTextBody: () => void;
preferTextBody: boolean;
@@ -1651,6 +1902,12 @@ function LogJsonBodyToolbar({
title: string;
}) {
const t = useAppText();
const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === "";
const canToggleJsonText = bodyView.json !== undefined && query.trim() === "";
const showToggleButton = canLoadFullBody || canToggleJsonText;
const toggleLabel = canLoadFullBody
? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content")
: preferTextBody ? "JSON" : t("Show full content");
return (
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
@@ -1664,15 +1921,20 @@ function LogJsonBodyToolbar({
value={query}
/>
</div>
{bodyView.json !== undefined && query.trim() === "" ? (
{showToggleButton ? (
<button
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
onClick={onToggleTextBody}
disabled={bodyView.loading && bodyView.mode === "full"}
onClick={canLoadFullBody ? onLoadFullBody : onToggleTextBody}
type="button"
>
{preferTextBody ? "JSON" : t("Show full content")}
{toggleLabel}
</button>
) : null}
{bodyView.loading ? <span className="network-muted shrink-0 text-[11px] font-semibold">{t("Loading full payload...")}</span> : null}
{bodyView.error ? <span className="network-error-box shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold">{bodyView.error}</span> : null}
{bodyView.preview ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("preview")}</span> : null}
{bodyView.sourceSizeBytes > 0 ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{formatBytes(bodyView.sourceSizeBytes)}</span> : null}
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
</div>
@@ -1706,6 +1968,7 @@ function LogJsonFullscreenViewer({
copyText,
expandedJsonPaths,
onClose,
onLoadFullBody,
onQueryChange,
onToggleJsonPath,
onToggleTextBody,
@@ -1718,11 +1981,12 @@ function LogJsonFullscreenViewer({
visible
}: {
body?: RequestLogBody;
bodyView: ReturnType<typeof formatLogBodyView>;
bodyView: LogBodyPanelView;
copyLabel: string;
copyText: string;
expandedJsonPaths: Set<string>;
onClose: () => void;
onLoadFullBody: () => void;
onQueryChange: (value: string) => void;
onToggleJsonPath: (path: string) => void;
onToggleTextBody: () => void;
@@ -1760,6 +2024,7 @@ function LogJsonFullscreenViewer({
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onLoadFullBody={onLoadFullBody}
onQueryChange={onQueryChange}
onToggleTextBody={onToggleTextBody}
preferTextBody={preferTextBody}
@@ -1798,27 +2063,7 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string {
].join("\u001f");
}
function cachedFormatLogBodyView(key: string, body: RequestLogBody | undefined): ReturnType<typeof formatLogBodyView> {
const cached = logBodyViewCache.get(key);
if (cached) {
logBodyViewCache.delete(key);
logBodyViewCache.set(key, cached);
return cached;
}
const value = formatLogBodyView(body);
logBodyViewCache.set(key, value);
while (logBodyViewCache.size > logBodyViewCacheLimit) {
const oldest = logBodyViewCache.keys().next().value;
if (!oldest) {
break;
}
logBodyViewCache.delete(oldest);
}
return value;
}
function createInitialVisibleJsonPaths(bodyView: ReturnType<typeof formatLogBodyView>): Set<string> {
function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set<string> {
if (!isJsonContainer(bodyView.json)) {
return new Set();
}
@@ -11,6 +11,7 @@ export * from "./profiles";
export * from "./services";
export * from "./provider-accounts";
export * from "./logs";
export * from "./log-body-worker-protocol";
export * from "./common";
export * from "./config";
export * from "./api-keys";
@@ -0,0 +1,103 @@
import type { RequestLogBody } from "@ccr/core/contracts/app";
import type { FormattedLogBody } from "./logs";
export const logBodyLargeTextThreshold = 256 * 1024;
export const logBodyPreviewTextLimit = 160 * 1024;
export type LogBodyFormatMode = "full" | "preview";
export type LogBodyFormatRequest = {
body?: RequestLogBody;
bodyKey: string;
id: number;
kind: "format";
largeTextThreshold?: number;
mode: LogBodyFormatMode;
previewTextLimit?: number;
query: string;
};
export type LogBodyFilterRequest = {
bodyKey: string;
id: number;
kind: "filter";
mode: LogBodyFormatMode;
query: string;
};
export type LogBodyWorkerRequest = LogBodyFilterRequest | LogBodyFormatRequest;
export type LogBodyFormatResult = FormattedLogBody & {
bodyKey: string;
formattedTextLength: number;
id: number;
kind: "format-result";
large: boolean;
mode: LogBodyFormatMode;
ok: true;
preview: boolean;
query: string;
sourceSizeBytes: number;
visible: string;
};
export type LogBodyFilterResult = {
bodyKey: string;
id: number;
kind: "filter-result";
mode: LogBodyFormatMode;
ok: true;
query: string;
visible: string;
};
export type LogBodyWorkerError = {
bodyKey?: string;
id: number;
kind: "error";
message: string;
mode?: LogBodyFormatMode;
operation: LogBodyWorkerRequest["kind"];
};
export type LogBodyWorkerResponse = LogBodyFilterResult | LogBodyFormatResult | LogBodyWorkerError;
export function isLargeLogBody(
body: RequestLogBody | undefined,
threshold = logBodyLargeTextThreshold
): boolean {
if (!body) {
return false;
}
return Math.max(body.sizeBytes, body.text.length) > threshold;
}
export function createLogBodyPreviewText(
body: RequestLogBody | undefined,
limit = logBodyPreviewTextLimit
): string {
if (!body || (!body.text && body.sizeBytes === 0)) {
return "No body";
}
const text = body.text || "";
if (!text) {
return "Body text is not loaded.";
}
if (text.length <= limit) {
return text;
}
const headLength = Math.max(0, Math.floor(limit * 0.65));
const tailLength = Math.max(0, limit - headLength);
const omitted = Math.max(0, text.length - headLength - tailLength);
const head = text.slice(0, headLength);
const tail = tailLength > 0 ? text.slice(-tailLength) : "";
return [
head,
"",
`... ${omitted} characters omitted from preview ...`,
"",
tail
].join("\n");
}
@@ -0,0 +1,100 @@
import {
createLogBodyPreviewText,
isLargeLogBody,
logBodyLargeTextThreshold,
logBodyPreviewTextLimit,
type LogBodyFilterRequest,
type LogBodyFormatRequest,
type LogBodyFormatResult,
type LogBodyWorkerRequest,
type LogBodyWorkerResponse
} from "./log-body-worker-protocol";
import { filterLogText, formatLogBodyView, type FormattedLogBody } from "./logs";
type CachedFormattedBody = FormattedLogBody & {
bodyKey: string;
large: boolean;
mode: LogBodyFormatRequest["mode"];
preview: boolean;
sourceSizeBytes: number;
};
type LogBodyWorkerGlobal = {
onmessage: ((event: MessageEvent<LogBodyWorkerRequest>) => void) | null;
postMessage: (message: LogBodyWorkerResponse) => void;
};
const worker = self as unknown as LogBodyWorkerGlobal;
let cachedBody: CachedFormattedBody | undefined;
worker.onmessage = (event: MessageEvent<LogBodyWorkerRequest>) => {
const request = event.data;
try {
if (request.kind === "format") {
worker.postMessage(formatBody(request) satisfies LogBodyWorkerResponse);
return;
}
worker.postMessage(filterBody(request) satisfies LogBodyWorkerResponse);
} catch (error) {
worker.postMessage({
bodyKey: "bodyKey" in request ? request.bodyKey : undefined,
id: request.id,
kind: "error",
message: error instanceof Error ? error.message : String(error),
mode: "mode" in request ? request.mode : undefined,
operation: request.kind
} satisfies LogBodyWorkerResponse);
}
};
function formatBody(request: LogBodyFormatRequest): LogBodyFormatResult {
const threshold = request.largeTextThreshold ?? logBodyLargeTextThreshold;
const previewLimit = request.previewTextLimit ?? logBodyPreviewTextLimit;
const large = isLargeLogBody(request.body, threshold);
const preview = large && request.mode !== "full";
const bodyView = preview
? { text: createLogBodyPreviewText(request.body, previewLimit) }
: formatLogBodyView(request.body);
const sourceSizeBytes = request.body?.sizeBytes ?? 0;
const visible = filterLogText(bodyView.text, request.query);
cachedBody = {
...bodyView,
bodyKey: request.bodyKey,
large,
mode: request.mode,
preview,
sourceSizeBytes
};
return {
...bodyView,
bodyKey: request.bodyKey,
formattedTextLength: bodyView.text.length,
id: request.id,
kind: "format-result",
large,
mode: request.mode,
ok: true,
preview,
query: request.query,
sourceSizeBytes,
visible
};
}
function filterBody(request: LogBodyFilterRequest): LogBodyWorkerResponse {
if (!cachedBody || cachedBody.bodyKey !== request.bodyKey || cachedBody.mode !== request.mode) {
throw new Error("Formatted body cache is not available.");
}
return {
bodyKey: request.bodyKey,
id: request.id,
kind: "filter-result",
mode: request.mode,
ok: true,
query: request.query,
visible: filterLogText(cachedBody.text, request.query)
};
}