From bdcd745c4d3001fec53bb085be957931079d4c4f Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 04:40:32 +0200 Subject: [PATCH] Add compact colored prettyJson helper for request logs. --- src/api/middleware/logger.js | 49 +++------ src/util/prettyJson.js | 198 +++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 src/util/prettyJson.js diff --git a/src/api/middleware/logger.js b/src/api/middleware/logger.js index cd14296..10508fe 100644 --- a/src/api/middleware/logger.js +++ b/src/api/middleware/logger.js @@ -1,33 +1,16 @@ import chalk from 'chalk'; +import { isEmpty, prettyJson } from '../../util/prettyJson.js'; -const BINARY_PREVIEW_BYTES = 16; - -function summarizeBuffer(buf) { - const head = buf.subarray(0, BINARY_PREVIEW_BYTES); - const hex = [...head].map((b) => b.toString(16).padStart(2, '0')).join(' '); - return { - type: 'Buffer', - length: buf.length, - headHex: hex, - }; -} - -function safeJson(value) { - try { - if (Buffer.isBuffer(value)) { - return JSON.stringify(summarizeBuffer(value), null, 2); - } - if ( - Array.isArray(value) && - value.length > 64 && - value.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) - ) { - return JSON.stringify(summarizeBuffer(Buffer.from(value)), null, 2); - } - return JSON.stringify(value, null, 2); - } catch { - return String(value); +function logField(label, value, { skipEmpty = false } = {}) { + if (skipEmpty && isEmpty(value)) return; + const rendered = prettyJson(value); + const prefix = chalk.gray(` ${label}: `); + if (!rendered.includes('\n')) { + console.log(prefix + rendered); + return; } + console.log(prefix); + console.log(rendered.replace(/^/gm, ' ')); } function captureResponseBody(res) { @@ -66,11 +49,13 @@ export const loggerMiddleware = (req, res, next) => { else status = chalk.green(status); console.log(`${method} ${url} ${status} ${chalk.gray(`(${duration}ms)`)}`); - console.log(chalk.gray(' params:'), safeJson(req.params ?? {})); - console.log(chalk.gray(' query:'), safeJson(req.query ?? {})); - console.log(chalk.gray(' content-type:'), req.headers['content-type'] ?? '(none)'); - console.log(chalk.gray(' body:'), safeJson(req.body ?? {})); - console.log(chalk.gray(' response:'), safeJson(getBody())); + logField('params', req.params ?? {}, { skipEmpty: true }); + logField('query', req.query ?? {}, { skipEmpty: true }); + if (req.headers['content-type']) { + console.log(chalk.gray(' content-type:'), chalk.cyan(req.headers['content-type'])); + } + logField('body', req.body ?? {}, { skipEmpty: true }); + logField('response', getBody()); }); next(); diff --git a/src/util/prettyJson.js b/src/util/prettyJson.js new file mode 100644 index 0000000..f5fd982 --- /dev/null +++ b/src/util/prettyJson.js @@ -0,0 +1,198 @@ +import chalk from 'chalk'; + +const BINARY_PREVIEW_BYTES = 16; +const DEFAULTS = { + maxLine: 100, + indent: ' ', + maxString: 96, + maxArray: 24, + colors: true, +}; + +function summarizeBuffer(buf) { + const head = buf.subarray(0, BINARY_PREVIEW_BYTES); + const hex = [...head].map((b) => b.toString(16).padStart(2, '0')).join(' '); + return { type: 'Buffer', length: buf.length, headHex: hex }; +} + +function prepareForLog(value) { + if (value === undefined) return undefined; + if (Buffer.isBuffer(value)) return summarizeBuffer(value); + if ( + Array.isArray(value) && + value.length > 64 && + value.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) + ) { + return summarizeBuffer(Buffer.from(value)); + } + return value; +} + +export function isEmpty(value) { + if (value == null) return true; + if (Buffer.isBuffer(value)) return value.length === 0; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === 'object') return Object.keys(value).length === 0; + return false; +} + +function stripAnsi(s) { + return s.replace(/\u001b\[[0-9;]*m/g, ''); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isScalar(value) { + return value === null || typeof value !== 'object'; +} + +/** Object/array only contains scalars (or empty containers) — always keep on one line. */ +function isFlat(value) { + if (isScalar(value)) return true; + if (Array.isArray(value)) return value.every(isScalar); + if (isPlainObject(value)) { + return Object.values(value).every( + (v) => + isScalar(v) || + (Array.isArray(v) && v.length === 0) || + (isPlainObject(v) && Object.keys(v).length === 0), + ); + } + return false; +} + +function paint(enabled, style, text) { + return enabled ? style(text) : text; +} + +function createFormatter(options) { + const maxLine = options.maxLine ?? DEFAULTS.maxLine; + const indent = options.indent ?? DEFAULTS.indent; + const maxString = options.maxString ?? DEFAULTS.maxString; + const maxArray = options.maxArray ?? DEFAULTS.maxArray; + const colors = options.colors ?? DEFAULTS.colors; + + const key = (name) => paint(colors, chalk.white, name); + const punct = (s) => paint(colors, chalk.white, s); + const sep = (s) => paint(colors, chalk.gray, s); + + function formatPrimitive(value) { + if (value === null) return paint(colors, chalk.bold.magenta, 'null'); + if (value === undefined) return paint(colors, chalk.dim, 'undefined'); + if (typeof value === 'boolean') return paint(colors, chalk.yellow, String(value)); + if (typeof value === 'number' || typeof value === 'bigint') { + return paint(colors, chalk.yellow, String(value)); + } + if (typeof value === 'string') { + const shown = value.length > maxString ? `${value.slice(0, maxString)}…` : value; + return paint(colors, chalk.green, JSON.stringify(shown)); + } + return paint(colors, chalk.cyan, String(value)); + } + + function formatFlatObject(value) { + const fields = Object.entries(value).map( + ([k, v]) => `${key(k)}${sep(':')} ${formatValue(v, 0)}`, + ); + if (fields.length === 0) return punct('{}'); + return `${punct('{')} ${fields.join(sep(', '))} ${punct('}')}`; + } + + function formatFlatArray(value) { + if (value.length === 0) return punct('[]'); + const items = value.slice(0, maxArray).map((item) => formatValue(item, 0)); + if (value.length > maxArray) { + items.push(paint(colors, chalk.dim, `…${value.length - maxArray} more`)); + } + return `${punct('[')} ${items.join(sep(', '))} ${punct(']')}`; + } + + function formatValue(value, depth) { + if (isScalar(value)) return formatPrimitive(value); + + if (Array.isArray(value)) { + if (value.length === 0) return punct('[]'); + if (isFlat(value) || value.every(isFlat)) { + const inline = formatFlatArray(value); + if (stripAnsi(inline).length <= maxLine || value.every(isScalar)) return inline; + const pad = indent.repeat(depth + 1); + const close = indent.repeat(depth); + const items = value.slice(0, maxArray).map((item) => formatValue(item, depth + 1)); + if (value.length > maxArray) { + items.push(paint(colors, chalk.dim, `…${value.length - maxArray} more`)); + } + return [punct('['), ...items.map((l) => `${pad}${l}`), `${close}${punct(']')}`].join('\n'); + } + + const pad = indent.repeat(depth + 1); + const close = indent.repeat(depth); + const items = value.slice(0, maxArray).map((item) => formatValue(item, depth + 1)); + if (value.length > maxArray) { + items.push(paint(colors, chalk.dim, `…${value.length - maxArray} more`)); + } + return [punct('['), ...items.map((l) => `${pad}${l}`), `${close}${punct(']')}`].join('\n'); + } + + if (isFlat(value)) return formatFlatObject(value); + + const pad = indent.repeat(depth + 1); + const close = indent.repeat(depth); + const entries = Object.entries(value); + const lines = []; + let run = []; + + const flushRun = () => { + if (!run.length) return; + let current = ''; + for (const field of run) { + const next = current ? `${current}${sep(', ')}${field}` : field; + if (current && stripAnsi(next).length > maxLine) { + lines.push(`${pad}${current}`); + current = field; + } else { + current = next; + } + } + if (current) lines.push(`${pad}${current}`); + run = []; + }; + + for (const [k, v] of entries) { + if (isFlat(v)) { + run.push(`${key(k)}${sep(':')} ${formatValue(v, depth + 1)}`); + continue; + } + flushRun(); + const nested = formatValue(v, depth + 1); + if (!nested.includes('\n')) { + lines.push(`${pad}${key(k)}${sep(':')} ${nested}`); + } else { + const [first, ...rest] = nested.split('\n'); + lines.push(`${pad}${key(k)}${sep(':')} ${first}`); + lines.push(...rest); + } + } + flushRun(); + + return [punct('{'), ...lines, `${close}${punct('}')}`].join('\n'); + } + + return formatValue; +} + +/** + * Compact, colored pretty-printer for logs (flat objects stay on one line). + * @param {unknown} value + * @param {{ maxLine?: number, indent?: string, maxString?: number, maxArray?: number, colors?: boolean }} [options] + * @returns {string} + */ +export function prettyJson(value, options = {}) { + try { + const formatValue = createFormatter(options); + return formatValue(prepareForLog(value), 0); + } catch { + return String(value); + } +}