monitor/core/lib/console.ts
2025-04-16 22:30:27 +07:00

389 lines
12 KiB
TypeScript

//NOTE: due to the monkey patching of the console, this should be imported before anything else
// which means in this file you cannot import anything from inside txAdmin to prevent cyclical dependencies
import { Console } from 'node:console';
import { InspectOptions } from 'node:util';
import { Writable } from 'node:stream';
import path from 'node:path';
import chalk, { ChalkInstance } from 'chalk';
import slash from 'slash';
import ErrorStackParser from 'error-stack-parser';
import sourceMapSupport from 'source-map-support';
//Buffer handler
//NOTE: the buffer will take between 64~72kb
const headBufferLimit = 8 * 1024; //4kb
const bodyBufferLimit = 64 * 1024; //64kb
const bodyTrimSliceSize = 8 * 1024;
const BUFFER_CUT_WARNING = chalk.bgRgb(255, 69, 0)('[!] The log body was sliced to prevent memory exhaustion. [!]');
const DEBUG_COLOR = chalk.bgHex('#FF45FF');
let headBuffer = '';
let bodyBuffer = '';
const writeToBuffer = (chunk: string) => {
//if head not full yet
if (headBuffer.length + chunk.length < headBufferLimit) {
headBuffer += chunk;
return;
}
//write to body and trim if needed
bodyBuffer += chunk;
if (bodyBuffer.length > bodyBufferLimit) {
let trimmedBody = bodyBuffer.slice(bodyTrimSliceSize - bodyBufferLimit);
trimmedBody = trimmedBody.substring(trimmedBody.indexOf('\n'));
bodyBuffer = `\n${BUFFER_CUT_WARNING}\n${trimmedBody}`;
}
}
export const getLogBuffer = () => headBuffer + bodyBuffer;
//Variables
const header = 'tx';
let stackPathAlias: { path: string, alias: string } | undefined;
let _txAdminVersion: string | undefined;
let _verboseFlag = false;
export const setConsoleEnvData = (
txAdminVersion: string,
txAdminResourcePath: string,
isDevMode: boolean,
isVerbose: boolean,
) => {
_txAdminVersion = txAdminVersion;
_verboseFlag = isVerbose;
if (isDevMode) {
sourceMapSupport.install();
//for some reason when using sourcemap it ends up with core/core/
stackPathAlias = {
path: txAdminResourcePath + '/core',
alias: '@monitor',
}
} else {
stackPathAlias = {
path: txAdminResourcePath,
alias: '@monitor',
}
}
}
/**
* STDOUT EOL helper
*/
let stdioEolPending = false;
export const processStdioWriteRaw = (buffer: Uint8Array | string) => {
if (!buffer.length) return;
const comparator = typeof buffer === 'string' ? '\n' : 10;
stdioEolPending = buffer[buffer.length - 1] !== comparator;
process.stdout.write(buffer);
}
export const processStdioEnsureEol = () => {
if (stdioEolPending) {
process.stdout.write('\n');
stdioEolPending = false;
}
}
/**
* New console and streams
*/
const defaultStream = new Writable({
decodeStrings: true,
defaultEncoding: 'utf8',
highWaterMark: 64 * 1024,
write(chunk, encoding, callback) {
writeToBuffer(chunk)
process.stdout.write(chunk);
callback();
},
});
const verboseStream = new Writable({
decodeStrings: true,
defaultEncoding: 'utf8',
highWaterMark: 64 * 1024,
write(chunk, encoding, callback) {
writeToBuffer(chunk)
if (_verboseFlag) process.stdout.write(chunk);
callback();
},
});
const defaultConsole = new Console({
//@ts-ignore some weird change from node v16 to v22, check after update
stdout: defaultStream,
stderr: defaultStream,
colorMode: true,
});
const verboseConsole = new Console({
//@ts-ignore some weird change from node v16 to v22, check after update
stdout: verboseStream,
stderr: verboseStream,
colorMode: true,
});
/**
* Returns current ts in h23 format
* FIXME: same thing as utils/misc.ts getTimeHms
*/
export const getTimestamp = () => (new Date).toLocaleString(
undefined,
{ timeStyle: 'medium', hourCycle: 'h23' }
);
/**
* Generated the colored log prefix (ts+tags)
*/
export const genLogPrefix = (currContext: string, color: ChalkInstance) => {
return color.black(`[${getTimestamp()}][${currContext}]`);
}
//Dir helpers
const cleanPath = (x: string) => slash(path.normalize(x));
const ERR_STACK_PREFIX = chalk.redBright(' => ');
const DIVIDER_SIZE = 60;
const DIVIDER_CHAR = '=';
const DIVIDER = DIVIDER_CHAR.repeat(DIVIDER_SIZE);
const DIR_DIVIDER = chalk.cyan(DIVIDER);
const specialsColor = chalk.rgb(255, 228, 181).italic;
const lawngreenColor = chalk.rgb(124, 252, 0);
const orangeredColor = chalk.rgb(255, 69, 0);
/**
* Parses an error and returns string with prettified error and stack
* The stack filters out node modules and aliases monitor folder
*/
const getPrettyError = (error: Error, multilineError?: boolean) => {
const out: string[] = [];
const prefixStr = `[${getTimestamp()}][tx]`;
let prefixColor = chalk.redBright;
let nameColor = chalk.redBright;
if (error.name === 'ExperimentalWarning') {
prefixColor = chalk.bgYellow.black;
nameColor = chalk.yellowBright;
} else if (multilineError) {
prefixColor = chalk.bgRed.black;
}
const prefix = prefixColor(prefixStr) + ' ';
//banner
out.push(prefix + nameColor(`${error.name}: `) + error.message);
if ('type' in error) out.push(prefix + nameColor('Type:') + ` ${error.type}`);
if ('code' in error) out.push(prefix + nameColor('Code:') + ` ${error.code}`);
//stack
if (typeof error.stack === 'string') {
const stackPrefix = multilineError ? prefix : ERR_STACK_PREFIX;
try {
for (const line of ErrorStackParser.parse(error)) {
if (line.fileName && line.fileName.startsWith('node:')) continue;
let outPath = cleanPath(line.fileName ?? 'unknown');
if(stackPathAlias){
outPath = outPath.replace(stackPathAlias.path, stackPathAlias.alias);
}
const outPos = chalk.blueBright(`${line.lineNumber}:${line.columnNumber}`);
const outName = chalk.yellowBright(line.functionName || '<unknown>');
if (!outPath.startsWith('@monitor/core')) {
out.push(chalk.dim(`${stackPrefix}${outPath} > ${outPos} > ${outName}`));
} else {
out.push(`${stackPrefix}${outPath} > ${outPos} > ${outName}`);
}
}
} catch (error) {
out.push(`${prefix} Unnable to parse error stack.`);
}
} else {
out.push(`${prefix} Error stack not available.`);
}
return out.join('\n');
}
/**
* Drop-in replacement for console.dir
*/
const dirHandler = (data: any, options?: TxInspectOptions, consoleInstance?: Console) => {
if (!consoleInstance) consoleInstance = defaultConsole;
if (data instanceof Error) {
consoleInstance.log(getPrettyError(data, options?.multilineError));
if (!options?.multilineError) consoleInstance.log();
} else {
consoleInstance.log(DIR_DIVIDER);
if (data === undefined) {
consoleInstance.log(specialsColor('> undefined'));
} else if (data === null) {
consoleInstance.log(specialsColor('> null'));
} else if (data instanceof Promise) {
consoleInstance.log(specialsColor('> Promise'));
} else if (typeof data === 'boolean') {
consoleInstance.log(data ? lawngreenColor('true') : orangeredColor('false'));
} else {
consoleInstance.dir(data, options);
}
consoleInstance.log(DIR_DIVIDER);
}
}
type TxInspectOptions = InspectOptions & {
multilineError?: boolean;
}
/**
* Cleans the terminal
*/
export const cleanTerminal = () => {
process.stdout.write('.\n'.repeat(80) + '\x1B[2J\x1B[H');
}
/**
* Sets terminal title
*/
export const setTTYTitle = (title?: string) => {
const txVers = _txAdminVersion ? `txAdmin v${_txAdminVersion}` : 'txAdmin';
const out = title ? `${title} - txAdmin` : txVers;
process.stdout.write(`\x1B]0;${out}\x07`);
}
/**
* Generates a custom log function with custom context and specific Console
*/
const getLogFunc = (
currContext: string,
color: ChalkInstance,
consoleInstance?: Console,
): LogFunction => {
return (message?: any, ...optParams: any) => {
if (!consoleInstance) consoleInstance = defaultConsole;
const prefix = genLogPrefix(currContext, color);
if (typeof message === 'string') {
return consoleInstance.log.call(null, `${prefix} ${message}`, ...optParams);
} else {
return consoleInstance.log.call(null, prefix, message, ...optParams);
}
}
}
//Reused types
type LogFunction = typeof Console.prototype.log;
type DirFunction = (data: any, options?: TxInspectOptions) => void;
interface TxBaseLogTypes {
debug: LogFunction;
log: LogFunction;
ok: LogFunction;
warn: LogFunction;
error: LogFunction;
dir: DirFunction;
}
/**
* Factory for console.log drop-ins
*/
const consoleFactory = (ctx?: string, subCtx?: string): CombinedConsole => {
const currContext = [header, ctx, subCtx].filter(x => x).join(':');
const baseLogs: TxBaseLogTypes = {
debug: getLogFunc(currContext, DEBUG_COLOR),
log: getLogFunc(currContext, chalk.bgBlue),
ok: getLogFunc(currContext, chalk.bgGreen),
warn: getLogFunc(currContext, chalk.bgYellow),
error: getLogFunc(currContext, chalk.bgRed),
dir: (data: any, options?: TxInspectOptions & {}) => dirHandler.call(null, data, options),
};
return {
...defaultConsole,
...baseLogs,
tag: (subCtx: string) => consoleFactory(ctx, subCtx),
multiline: (text: string | string[], color: ChalkInstance) => {
if (!Array.isArray(text)) text = text.split('\n');
const prefix = genLogPrefix(currContext, color);
for (const line of text) {
defaultConsole.log(prefix, line);
}
},
/**
* Prints a multiline error message with a red background
* @param text
*/
majorMultilineError: (text: string | (string | null)[]) => {
if (!Array.isArray(text)) text = text.split('\n');
const prefix = genLogPrefix(currContext, chalk.bgRed);
defaultConsole.log(prefix, DIVIDER);
for (const line of text) {
if (line) {
defaultConsole.log(prefix, line);
} else {
defaultConsole.log(prefix, DIVIDER);
}
}
defaultConsole.log(prefix, DIVIDER);
},
//Returns a set of log functions that will be executed after a delay
defer: (ms = 250) => ({
debug: (...args) => setTimeout(() => baseLogs.debug(...args), ms),
log: (...args) => setTimeout(() => baseLogs.log(...args), ms),
ok: (...args) => setTimeout(() => baseLogs.ok(...args), ms),
warn: (...args) => setTimeout(() => baseLogs.warn(...args), ms),
error: (...args) => setTimeout(() => baseLogs.error(...args), ms),
dir: (...args) => setTimeout(() => baseLogs.dir(...args), ms),
}),
//Log functions that will output tothe verbose stream
verbose: {
debug: getLogFunc(currContext, DEBUG_COLOR, verboseConsole),
log: getLogFunc(currContext, chalk.bgBlue, verboseConsole),
ok: getLogFunc(currContext, chalk.bgGreen, verboseConsole),
warn: getLogFunc(currContext, chalk.bgYellow, verboseConsole),
error: getLogFunc(currContext, chalk.bgRed, verboseConsole),
dir: (data, options) => dirHandler.call(null, data, options, verboseConsole)
},
//Verbosity getter and explicit setter
get isVerbose() {
return _verboseFlag
},
setVerbose: (state: boolean) => {
_verboseFlag = !!state;
},
//Consts used by the fatalError util
DIVIDER,
DIVIDER_CHAR,
DIVIDER_SIZE,
};
};
export default consoleFactory;
interface CombinedConsole extends TxConsole, Console {
dir: DirFunction;
}
export interface TxConsole extends TxBaseLogTypes {
tag: (subCtx: string) => TxConsole;
multiline: (text: string | string[], color: ChalkInstance) => void;
majorMultilineError: (text: string | (string | null)[]) => void;
defer: (ms?: number) => TxBaseLogTypes;
verbose: TxBaseLogTypes;
readonly isVerbose: boolean;
setVerbose: (state: boolean) => void;
DIVIDER: string;
DIVIDER_CHAR: string;
DIVIDER_SIZE: number;
}
/**
* Replaces the global console with the new one
*/
global.console = consoleFactory();