Add compact colored prettyJson helper for request logs.

This commit is contained in:
seb
2026-08-01 04:40:32 +02:00
parent 71c8507805
commit bdcd745c4d
2 changed files with 215 additions and 32 deletions

View File

@@ -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();

198
src/util/prettyJson.js Normal file
View File

@@ -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);
}
}