import * as util from 'util'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as moment from 'moment'; import * as colors from 'colors/safe'; import { SourceMapConsumer } from 'source-map'; import * as StackTrace from '../stack-trace/stack-trace'; /** * Pads a string toward the center * @param x The value to pad * @param p The padding to use * @param n The end-length of the string * @returns The padded string */ function padCenter(x: string, p: string, n: number) { let z = false; while (x.length < n) { if (z) { z = false; x = p + x; } else { z = true; x = x + p; } } return x; } enum LoggerLevel { Fatal, Error, Warn, Info, Debug, Silly }; /** * Colorizes text * @param level The color level * @param text The text to colorize * @returns The colored text */ function colorize(level: LoggerLevel, text: string): string { switch (level) { case LoggerLevel.Fatal: return colors.bgRed(colors.white(text)); case LoggerLevel.Error: return colors.red(text); case LoggerLevel.Warn: return colors.yellow(text); case LoggerLevel.Info: return colors.green(text); case LoggerLevel.Debug: return colors.blue(text); case LoggerLevel.Silly: return colors.magenta(text); default: return colors.bgWhite(text); } } /** * @param jsFile The compiled javascript file * @param tsRelativeFile The source typescript file relative to jsFile's directory * @returns The path to tsRelativeFile based on the root directory */ function getPrintedPath(jsFile: string, tsRelativeFile: string) { let rootFile = path.join(__dirname, '..', '..'); let jsDir = path.dirname(jsFile); let tsPath = path.resolve(jsDir, tsRelativeFile); return path.relative(rootFile, tsPath); } /** * @param dir The directory to search * @returns an async iterator to all files in a directory (recursively searched) */ async function* getAllFiles(dir: string): AsyncIterable { let filesAndDirs = await fs.readdir(dir, { withFileTypes: true }); for (let next of filesAndDirs) { if (next.isFile()) { yield path.join(dir, next.name); } else if (next.isDirectory()) { let nextFiles = getAllFiles(path.join(dir, next.name)); for await (let nextFile of nextFiles) { yield nextFile; } } } } function tryCreateTSFrame(sourceMaps: Map, frame: StackTrace.Frame): StackTrace.Frame { if (!frame.fileName) return frame; let mapping = sourceMaps.get(frame.fileName); if (!mapping) return frame; if (!frame.line || !frame.column) return frame; let pos = mapping.originalPositionFor({ line: frame.line, column: frame.column }); if (!pos.source) return frame; return { functionName: frame.functionName, fileName: pos.source, line: pos.line ?? undefined, column: pos.column ?? undefined } } // I know, I know. But it takes all this disgusting code to make sure we only have to load the source maps once per module instance // instead of spamming the hard drive let STATIC_SOURCE_MAPS_LOADING = false; let STATIC_SOURCE_MAPS_CALLBACKS: ((sourceMaps: Map) => void)[] = []; let STATIC_SOURCE_MAPS: Map | null = null; async function getStaticSourceMaps(): Promise> { if (STATIC_SOURCE_MAPS !== null) { return STATIC_SOURCE_MAPS; } else if (STATIC_SOURCE_MAPS_LOADING) { return new Promise>((resolve) => { STATIC_SOURCE_MAPS_CALLBACKS.push(resolve); }); } else { STATIC_SOURCE_MAPS_LOADING = true; let sourceMaps = new Map(); for await (let file of getAllFiles(path.join(__dirname, '..'))) { if (!file.endsWith('.js')) continue; if (!await fs.stat(file + '.map').then(() => true).catch(() => false)) continue; // make sure .map file exists let fileBuff = await fs.readFile(file + '.map'); if (typeof window !== 'undefined' && URL && URL.createObjectURL) { // Only run this stuff if we are running in a browser window let mappingsWasmBuffer = await fs.readFile(path.join(__dirname, '../../node_modules/source-map/lib/mappings.wasm')); let mappingsWasmBlob = new Blob([ mappingsWasmBuffer ], { type: 'application/wasm' }); //@ts-ignore Typescript is missing this function... Probably because it is static SourceMapConsumer.initialize({ "lib/mappings.wasm": URL.createObjectURL(mappingsWasmBlob) }); } let sourceMapConsumer = await new SourceMapConsumer(fileBuff.toString()); sourceMaps.set(file, sourceMapConsumer); } STATIC_SOURCE_MAPS = sourceMaps; STATIC_SOURCE_MAPS_LOADING = false; for (let callback of STATIC_SOURCE_MAPS_CALLBACKS) { callback(sourceMaps); } STATIC_SOURCE_MAPS_CALLBACKS = []; return STATIC_SOURCE_MAPS; } } export default class Logger { private name: string; private console: Console; private sourceMaps = new Map(); private constructor(name: string, processConsole?: Console) { this.name = name; this.console = processConsole ?? console; } static create(name: string, processConsole?: Console) { let log = new Logger(name, processConsole); (async () => { let sourceMaps = await getStaticSourceMaps(); log.sourceMaps = sourceMaps; })(); return log; } private log(level: LoggerLevel, message: string | null, data?: Error | any): void { let frames: StackTrace.Frame[] = StackTrace.parse(new Error()); let jsFrame = frames[2]; let tsFrame = tryCreateTSFrame(this.sourceMaps, jsFrame); let nameDisplay = tsFrame.fileName ? `${getPrintedPath(jsFrame.fileName, tsFrame.fileName)}:${tsFrame.line}:${tsFrame.column}` : this.name; // this.name; let prefix: string = `[ ${colorize(level, padCenter(LoggerLevel[level].toLowerCase(), ' ', 5))} | ${nameDisplay} | ${moment().format('HH:mm:ss.SSS')} ]`; let out: string = ''; if (message !== null) { out += message.split('\n').map(o => `${prefix}: ${o}`).join('\n'); } let handleData = (data: Error | any) => { if (data) { if (message !== null) { out += '\n'; } if (data instanceof Error) { if (data.stack) { let errorFrames = StackTrace.parse(data); let tsStackLines = errorFrames .map((frame: StackTrace.Frame) => tryCreateTSFrame(this.sourceMaps, frame)) .map((frame: StackTrace.Frame) => StackTrace.getFrameLine(frame)); out += `${prefix}# ${data.name}: ${data.message}`; out += `\n${prefix}# ${tsStackLines.join(`\n${prefix}# `)}`; //out += `\n${prefix}# ${data.stack.split('\n').join(`\n${prefix}# `)}`; } else { out += `${prefix}# ${data.name}: ${data.message}`; } // Use the source map to create a typescript version of the stack // This will be printed asynchronously because SourceMapConsumer } else { let s = util.inspect(data, { colors: true }); s = s.split('\n').map(o => `${prefix}$ ${o}`).join('\n'); out += s; } } } if (Array.isArray(data)) { data.forEach(handleData); } else { handleData(data); } this.console.log(out); } public fatal(message: string | null, data?: Error | any): void { this.log(LoggerLevel.Fatal, message, data); } public error(message: string | null, data?: Error | any): void { this.log(LoggerLevel.Error, message, data); } public warn( message: string | null, data?: Error | any): void { this.log(LoggerLevel.Warn, message, data); } public info( message: string | null, data?: Error | any): void { this.log(LoggerLevel.Info, message, data); } public debug(message: string | null, data?: Error | any): void { this.log(LoggerLevel.Debug, message, data); } public silly(message: string | null, data?: Error | any): void { this.log(LoggerLevel.Silly, message, data); } public inspect(object: any, showHidden?: boolean, depth?: number, color?: boolean): string { return util.inspect(object, showHidden, depth, color); } }