diff --git a/package.json b/package.json index e68edda..3dd85d1 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test test/*.test.js test/adapters/*.test.js" + "test": "node --import tsx --test test/*.test.js test/**/*.test.js" }, "repository": { "type": "git", diff --git a/src/core/formatter.ts b/src/core/formatter.ts new file mode 100644 index 0000000..b12ecd6 --- /dev/null +++ b/src/core/formatter.ts @@ -0,0 +1,43 @@ +export interface FormatPayloadOptions { + maxDepth?: number; // Maximum depth before collapsing inner objects (default: 4) + maxArrayItems?: number; // Maximum array items to show before truncating (default: 10) +} + +/** + * Safely format and truncate deep JSON structures to prevent terminal spam. + */ +export function formatPayload(data: any, options: FormatPayloadOptions = {}, currentDepth: number = 1): any { + const maxDepth = options.maxDepth != null ? options.maxDepth : 4; + const maxArrayItems = options.maxArrayItems != null ? options.maxArrayItems : 10; + + if (data === null || data === undefined) { + return data; + } + + if (typeof data !== 'object') { + return data; + } + + if (currentDepth > maxDepth) { + if (Array.isArray(data)) { + return `[Array(${data.length})]`; + } + return '[Object]'; + } + + if (Array.isArray(data)) { + if (data.length > maxArrayItems) { + const truncatedSlice = data + .slice(0, maxArrayItems) + .map((item) => formatPayload(item, options, currentDepth + 1)); + return [...truncatedSlice, `... ${data.length - maxArrayItems} more items` as any]; + } + return data.map((item) => formatPayload(item, options, currentDepth + 1)); + } + + const result: Record = {}; + for (const key of Object.keys(data)) { + result[key] = formatPayload(data[key], options, currentDepth + 1); + } + return result; +} diff --git a/src/core/stream.ts b/src/core/stream.ts new file mode 100644 index 0000000..de747a1 --- /dev/null +++ b/src/core/stream.ts @@ -0,0 +1,52 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export interface StreamCaptureOptions { + maxBodySize?: number; // Maximum bytes to capture before dropping further chunks (default: 1024) +} + +export interface CapturedStreamResult { + body: string; + truncated: boolean; + sizeBytes: number; +} + +/** + * Utility to safely collect raw stream body chunks up to maxBodySize limit. + */ +export function createStreamCapturer(maxBodySize: number = 1024) { + let chunks: Buffer[] = []; + let currentSize = 0; + let truncated = false; + + return { + onData(chunk: any) { + if (truncated) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + currentSize += buf.length; + + if (currentSize > maxBodySize) { + const allowedBytes = maxBodySize - (currentSize - buf.length); + if (allowedBytes > 0) { + chunks.push(buf.subarray(0, allowedBytes)); + } + truncated = true; + } else { + chunks.push(buf); + } + }, + getResult(): CapturedStreamResult { + const buffer = Buffer.concat(chunks); + let bodyText = ''; + try { + bodyText = buffer.toString('utf-8'); + } catch { + bodyText = '[Binary Data]'; + } + return { + body: bodyText, + truncated, + sizeBytes: currentSize, + }; + }, + }; +} diff --git a/src/middleware.ts b/src/middleware.ts index 72b11da..7a898fe 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,8 @@ import store from './store.ts'; import { getSystemMetrics } from './system.ts'; import { redactHeaders, generateCurl } from './utils.ts'; import type { AlertOptions } from './store.ts'; +import { createStreamCapturer } from './core/stream.ts'; +import { formatPayload } from './core/formatter.ts'; export interface ExpressLensOptions { logAnalytics?: boolean; @@ -9,6 +11,9 @@ export interface ExpressLensOptions { colorize?: boolean; ignoreRoutes?: (string | RegExp)[]; slowThresholdMs?: number; + maxBodySize?: number; + maxDepth?: number; + maxArrayItems?: number; redactHeaders?: string[]; alerts?: AlertOptions; } diff --git a/test/core/formatter.test.js b/test/core/formatter.test.js new file mode 100644 index 0000000..de76181 --- /dev/null +++ b/test/core/formatter.test.js @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { formatPayload } from '../../src/core/formatter.ts'; + +test('Payload Formatter Suite', async (t) => { + await t.test('collapses objects exceeding maxDepth', () => { + const deepObj = { level1: { level2: { level3: { level4: { level5: 'hidden' } } } } }; + const formatted = formatPayload(deepObj, { maxDepth: 3 }); + + assert.deepStrictEqual(formatted, { + level1: { level2: { level3: '[Object]' } } + }); + }); + + await t.test('truncates array items exceeding maxArrayItems', () => { + const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const formatted = formatPayload(arr, { maxArrayItems: 5 }); + + assert.strictEqual(formatted.length, 6); + assert.strictEqual(formatted[5], '... 7 more items'); + }); +}); diff --git a/test/core/stream.test.js b/test/core/stream.test.js new file mode 100644 index 0000000..bd39ce9 --- /dev/null +++ b/test/core/stream.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { createStreamCapturer } from '../../src/core/stream.ts'; + +test('Stream Capturer Suite', async (t) => { + await t.test('captures small chunks within limit', () => { + const capturer = createStreamCapturer(1024); + capturer.onData('Hello '); + capturer.onData('World!'); + + const result = capturer.getResult(); + assert.strictEqual(result.body, 'Hello World!'); + assert.strictEqual(result.truncated, false); + assert.strictEqual(result.sizeBytes, 12); + }); + + await t.test('truncates payload exceeding maxBodySize', () => { + const capturer = createStreamCapturer(10); + capturer.onData('1234567890EXTRA_DATA'); + + const result = capturer.getResult(); + assert.strictEqual(result.body, '1234567890'); + assert.strictEqual(result.truncated, true); + assert.strictEqual(result.sizeBytes, 20); + }); +});