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'; const LINE_NUM_LENGTH = 3; const MAX_FILE_NAME_PADDING = 22; const baseDir = path.join(__dirname, '..', '..'); /** Adds padding to the left of a string */ function padLeft(string: string, padding: string, minLength: number) { while (string.length < minLength) { string = padding + string; } return string; } /** Adds padding to the right of a string */ function padRight(string: string, padding: string, minLength: number) { while (string.length < minLength) { string = string + padding; } return string; } /** * @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; } } } } /** * @param sourceMaps A mapping from .js file to SourceMapConsumer created from a corresponding .js.map file * @param frame The StackTrace.Frame to try to convert into typescript format * @returns A StackTrace.Frame that is in terms of the corresponding typescript file or the same StackTrace.Frame * if the conversion couldn't be done (i.e. it's an internal node code) */ 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: path.resolve(path.dirname(frame.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. To be honest, why do you care about this file anyway. It's just the logger :) 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; } } 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); } } export default class Logger { private name: string; private console: Console; private sourceMaps = new Map(); private hasSourceMaps = false; private sourceMapsWaiters: (() => void)[] = []; private longestFileNameLength: number = 0; private constructor(name: string, processConsole?: Console) { this.name = name; this.console = processConsole ?? console; } /** Creates a new logger. The logger will print in terms of the dist .js files until it loads the TypeScript mappings */ static create(name: string, processConsole?: Console) { let log = new Logger(name, processConsole); (async () => { let sourceMaps = await getStaticSourceMaps(); log.sourceMaps = sourceMaps; log.onGetSourceMaps(); })(); return log; } private onGetSourceMaps() { this.hasSourceMaps = true; for (let sourceMapWaiter of this.sourceMapsWaiters) { sourceMapWaiter(); } let longestFileName = Array.from(this.sourceMaps.keys()) .map(filePath => path.basename(filePath)) .reduce((longestFileName, fileName) => fileName.length > longestFileName.length ? fileName : longestFileName, ''); this.longestFileNameLength = longestFileName.length; } public async ensureSourceMaps(): Promise { if (this.hasSourceMaps) return; return new Promise((resolve) => { this.sourceMapsWaiters.push(resolve); }); } public getTSStackFrames(err: Error): StackTrace.Frame[] { let jsFrames: StackTrace.Frame[] = StackTrace.parse(err); let tsFrames = jsFrames.map(jsFrame => tryCreateTSFrame(this.sourceMaps, jsFrame)); tsFrames = tsFrames.map(tsFrame => StackTrace.relativizeFilePath(tsFrame, baseDir)); return tsFrames; } /** Logs a message and potentially corresponding data */ 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 fileName: string; let line: number; if (tsFrame.fileName) { fileName = path.basename(getPrintedPath(jsFrame.fileName, tsFrame.fileName)); line = tsFrame.line ?? NaN; } else { fileName = this.name; line = NaN; } let prefixFileName = padLeft(fileName, ' ', MAX_FILE_NAME_PADDING); // Math.min(this.longestFileNameLength, ) let prefixLine = padRight(line + '', ' ', LINE_NUM_LENGTH); let prefixLogType = colorize(level, LoggerLevel[level].charAt(0).toLowerCase()); let prefixTime = moment().format('HH:mm:ss.SSS'); let prefix: string = `${prefixTime} ${prefixFileName}:${prefixLine} ${prefixLogType}`; 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 = this.getTSStackFrames(data); let tsStackLines = errorFrames .map((frame: StackTrace.Frame) => StackTrace.getFrameLine(frame)); out += `${prefix}# ${data.name}: ${data.message}`; out += `\n${prefix}# ${tsStackLines.join(`\n${prefix}# `)}`; //out += `${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, depth: 2 }); 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); } // Wrapper for util.inspect in case we want to do anything special in the future public inspect(object: any, showHidden?: boolean, depth?: number, color?: boolean): string { return util.inspect(object, showHidden, depth, color); } }