Add compact colored prettyJson helper for request logs.
This commit is contained in:
@@ -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
198
src/util/prettyJson.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user