diff --git a/src/common/util/objectUtils.ts b/src/common/util/objectUtils.ts index b62dfe895..78520a160 100644 --- a/src/common/util/objectUtils.ts +++ b/src/common/util/objectUtils.ts @@ -40,3 +40,173 @@ export function stripUndefined(obj: T | null): T | null { if (!obj) return null; return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T; } + + +// === Size Estimation === + +/** + * Estimates JSON serialized size without actually stringifying. + * + * Avoids memory allocation spike on large objects. Useful for progress tracking, + * batching decisions, or debug output sizing. + * + * Note: This is an ESTIMATE. It doesn't account for: + * - UTF-8 multi-byte characters (assumes 1 byte per char) + * - JSON escape sequences (\n, \t, unicode escapes) + * - Floating point precision differences + * + * Returns 0 for the cyclic portion if circular references are detected. + */ +export function objectEstimateJsonSize(value: unknown, debugCaller: string): number { + const seen = new WeakSet(); + + function estimate(val: unknown): number { + if (val === null) return 4; // "null" + if (val === undefined) return 0; // omitted in JSON + + switch (typeof val) { + case 'string': + return val.length + 2; // quotes + case 'number': + return String(val).length; + case 'boolean': + return val ? 4 : 5; // "true" or "false" + case 'object': { + // Cycle detection + if (seen.has(val as object)) { + console.warn(`[estimateJsonSize (${debugCaller})] Circular reference detected, returning 0 for this branch`); + return 0; + } + seen.add(val as object); + + if (Array.isArray(val)) { + let size = 2; // [] + for (let i = 0; i < val.length; i++) { + size += estimate(val[i]); + if (i < val.length - 1) size += 1; // comma + } + return size; + } + + // Plain object + let size = 2; // {} + const keys = Object.keys(val); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + size += key.length + 3; // "key": + size += estimate((val as Record)[key]); + if (i < keys.length - 1) size += 1; // comma + } + return size; + } + default: + return 0; + } + } + + return estimate(value); +} + + +// === Object Traversal === + +/** + * Deep clones an object while truncating strings that exceed maxBytes. + * + * Useful for debug logging of large objects (e.g., requests with base64 images). + * Truncates strings in the middle, preserving start and end with a byte count. + * + * @returns Deep clone with truncated strings, or "[Circular]" for cyclic refs + */ +export function objectDeepCloneWithStringLimit(value: unknown, debugCaller: string, maxBytes: number = 2048): unknown { + const seen = new WeakSet(); + + function clone(val: unknown): unknown { + // Handle primitives + if (val === null || val === undefined) return val; + + // Handle strings - truncate if too long + if (typeof val === 'string') { + if (val.length <= maxBytes) return val; + const ellipsis = `...[${(val.length - maxBytes).toLocaleString()} bytes]...`; + const half = Math.floor((maxBytes - ellipsis.length) / 2); + return val.slice(0, half) + ellipsis + val.slice(-half); + } + + // Handle other primitives + if (typeof val !== 'object') return val; + + // Cycle detection + if (seen.has(val)) return '[Circular]'; + seen.add(val); + + // Handle arrays + if (Array.isArray(val)) { + return val.map(item => clone(item)); + } + + // Handle objects + const result: Record = {}; + for (const key in val) { + if (Object.prototype.hasOwnProperty.call(val, key)) { + result[key] = clone((val as Record)[key]); + } + } + return result; + } + + return clone(value); +} + +/** + * Find the largest string values in an object tree + * + * Recursively traverses an object to find the top N largest string values, + * returning their paths, lengths, and preview snippets. + * + * @returns Array of {path, length, preview} sorted by length (descending) + */ +export function objectFindLargestStringPaths(obj: unknown, debugCaller: string, topN: number = 5, maxDepth: number = 20): Array<{ path: string; length: number; preview: string }> { + const results: Array<{ path: string; length: number; preview: string }> = []; + const seen = new WeakSet(); + + function traverse(current: unknown, path: string, depth: number) { + // prevent infinite recursion + if (depth > maxDepth) return; + + // handle strings + if (typeof current === 'string') { + results.push({ + path, + length: current.length, + preview: current.substring(0, 100) + (current.length > 100 ? '...' : ''), + }); + return; + } + + // handle non-objects + if (current === null || typeof current !== 'object') return; + + // cycle detection + if (seen.has(current)) { + console.warn(`[findLargestStringPaths (${debugCaller}] Circular reference at path: ${path}`); + return; + } + seen.add(current); + + // handle arrays + if (Array.isArray(current)) + return current.forEach((item, index) => traverse(item, `${path}[${index}]`, depth + 1)); + + // handle objects + for (const [key, value] of Object.entries(current)) + traverse(value, path ? `${path}.${key}` : key, depth + 1); + } + + traverse(obj, '', 0); + + // sort by length descending and return top N + return results + .sort((a, b) => b.length - a.length) + .slice(0, topN); +}