442 lines
10 KiB
TypeScript
442 lines
10 KiB
TypeScript
/**
|
|
* Log level.
|
|
*/
|
|
export enum Level {
|
|
Trace,
|
|
Debug,
|
|
Info,
|
|
Warning,
|
|
Error,
|
|
}
|
|
|
|
/**
|
|
* A field to log.
|
|
*/
|
|
export class Field<T> {
|
|
public constructor(
|
|
public readonly identifier: string,
|
|
public readonly value: T,
|
|
) { }
|
|
|
|
/**
|
|
* Convert field to JSON.
|
|
*/
|
|
public toJSON(): object {
|
|
return {
|
|
identifier: this.identifier,
|
|
value: this.value,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents the time something takes.
|
|
*/
|
|
export class Time {
|
|
public constructor(
|
|
public readonly expected: number,
|
|
public readonly ms: number,
|
|
) { }
|
|
}
|
|
|
|
// `undefined` is allowed to make it easy to conditionally display a field.
|
|
// For example: `error && field("error", error)`
|
|
// tslint:disable-next-line no-any
|
|
export type FieldArray = Array<Field<any> | undefined>;
|
|
|
|
// Functions can be used to remove the need to perform operations when the
|
|
// logging level won't output the result anyway.
|
|
export type LogCallback = () => [string, ...FieldArray];
|
|
|
|
/**
|
|
* Creates a time field
|
|
*/
|
|
export const time = (expected: number): Time => {
|
|
return new Time(expected, Date.now());
|
|
};
|
|
|
|
export const field = <T>(name: string, value: T): Field<T> => {
|
|
return new Field(name, value);
|
|
};
|
|
|
|
/**
|
|
* This formats & builds text for logging.
|
|
* It should only be used to build one log item at a time since it stores the
|
|
* currently built items and appends to that.
|
|
*/
|
|
export abstract class Formatter {
|
|
protected format = "";
|
|
protected args = <string[]>[];
|
|
|
|
/**
|
|
* Add a tag.
|
|
*/
|
|
public abstract tag(name: string, color: string): void;
|
|
|
|
/**
|
|
* Add string or arbitrary variable.
|
|
*/
|
|
public abstract push(arg: string, color?: string, weight?: string): void;
|
|
public abstract push(arg: any): void; // tslint:disable-line no-any
|
|
|
|
// tslint:disable-next-line no-any
|
|
public abstract fields(fields: Array<Field<any>>): void;
|
|
|
|
/**
|
|
* Flush out the built arguments.
|
|
*/
|
|
public flush(): any[] { // tslint:disable-line no-any
|
|
const args = [this.format, ...this.args];
|
|
this.format = "";
|
|
this.args = [];
|
|
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Get the format string for the value type.
|
|
*/
|
|
protected getType(arg: any): string { // tslint:disable-line no-any
|
|
switch (typeof arg) {
|
|
case "object":
|
|
return "%o";
|
|
case "number":
|
|
return "%d";
|
|
default:
|
|
return "%s";
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Browser formatter.
|
|
*/
|
|
export class BrowserFormatter extends Formatter {
|
|
public tag(name: string, color: string): void {
|
|
this.format += `%c ${name} `;
|
|
this.args.push(
|
|
`border: 1px solid #222; background-color: ${color}; padding-top: 1px;`
|
|
+ " padding-bottom: 1px; font-size: 12px; font-weight: bold; color: white;"
|
|
+ (name.length === 4 ? "padding-left: 3px; padding-right: 4px;" : ""),
|
|
);
|
|
// A space to separate the tag from the title.
|
|
this.push(" ");
|
|
}
|
|
|
|
public push(arg: any, color: string = "inherit", weight: string = "normal"): void { // tslint:disable-line no-any
|
|
if (color || weight) {
|
|
this.format += "%c";
|
|
this.args.push(
|
|
(color ? `color: ${color};` : "") +
|
|
(weight ? `font-weight: ${weight};` : ""),
|
|
);
|
|
}
|
|
this.format += this.getType(arg);
|
|
this.args.push(arg);
|
|
}
|
|
|
|
// tslint:disable-next-line no-any
|
|
public fields(fields: Array<Field<any>>): void {
|
|
// tslint:disable-next-line no-console
|
|
console.groupCollapsed(...this.flush());
|
|
fields.forEach((field) => {
|
|
this.push(field.identifier, "#3794ff", "bold");
|
|
if (typeof field.value !== "undefined" && field.value.constructor && field.value.constructor.name) {
|
|
this.push(` (${field.value.constructor.name})`);
|
|
}
|
|
this.push(": ");
|
|
this.push(field.value);
|
|
// tslint:disable-next-line no-console
|
|
console.log(...this.flush());
|
|
});
|
|
// tslint:disable-next-line no-console
|
|
console.groupEnd();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Server (Node) formatter.
|
|
*/
|
|
export class ServerFormatter extends Formatter {
|
|
public tag(name: string, color: string): void {
|
|
const [r, g, b] = this.hexToRgb(color);
|
|
while (name.length < 5) {
|
|
name += " ";
|
|
}
|
|
this.format += "\u001B[1m";
|
|
this.format += `\u001B[38;2;${r};${g};${b}m${name} \u001B[0m`;
|
|
}
|
|
|
|
public push(arg: any, color?: string, weight?: string): void { // tslint:disable-line no-any
|
|
if (weight === "bold") {
|
|
this.format += "\u001B[1m";
|
|
}
|
|
if (color) {
|
|
const [r, g, b] = this.hexToRgb(color);
|
|
this.format += `\u001B[38;2;${r};${g};${b}m`;
|
|
}
|
|
this.format += this.getType(arg);
|
|
if (weight || color) {
|
|
this.format += "\u001B[0m";
|
|
}
|
|
this.args.push(arg);
|
|
}
|
|
|
|
// tslint:disable-next-line no-any
|
|
public fields(fields: Array<Field<any>>): void {
|
|
// tslint:disable-next-line no-any
|
|
const obj: { [key: string]: any} = {};
|
|
this.format += "\u001B[38;2;140;140;140m";
|
|
fields.forEach((field) => {
|
|
obj[field.identifier] = field.value;
|
|
});
|
|
this.args.push(JSON.stringify(obj));
|
|
console.log(...this.flush()); // tslint:disable-line no-console
|
|
}
|
|
|
|
/**
|
|
* Convert fully-formed hex to rgb.
|
|
*/
|
|
private hexToRgb(hex: string): [number, number, number] {
|
|
const integer = parseInt(hex.substr(1), 16);
|
|
|
|
return [
|
|
(integer >> 16) & 0xFF,
|
|
(integer >> 8) & 0xFF,
|
|
integer & 0xFF,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class for logging.
|
|
*/
|
|
export class Logger {
|
|
public level = Level.Info;
|
|
|
|
private readonly nameColor?: string;
|
|
private muted: boolean = false;
|
|
|
|
public constructor(
|
|
private _formatter: Formatter,
|
|
private readonly name?: string,
|
|
private readonly defaultFields?: FieldArray,
|
|
) {
|
|
if (name) {
|
|
this.nameColor = this.hashStringToColor(name);
|
|
}
|
|
const envLevel = typeof global !== "undefined" && typeof global.process !== "undefined" ? global.process.env.LOG_LEVEL : process.env.LOG_LEVEL;
|
|
if (envLevel) {
|
|
switch (envLevel) {
|
|
case "trace": this.level = Level.Trace; break;
|
|
case "debug": this.level = Level.Debug; break;
|
|
case "info": this.level = Level.Info; break;
|
|
case "warn": this.level = Level.Warning; break;
|
|
case "error": this.level = Level.Error; break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public set formatter(formatter: Formatter) {
|
|
this._formatter = formatter;
|
|
}
|
|
|
|
/**
|
|
* Supresses all output
|
|
*/
|
|
public mute(): void {
|
|
this.muted = true;
|
|
}
|
|
|
|
/**
|
|
* Outputs information.
|
|
*/
|
|
public info(fn: LogCallback): void;
|
|
public info(message: string, ...fields: FieldArray): void;
|
|
public info(message: LogCallback | string, ...fields: FieldArray): void {
|
|
this.handle({
|
|
type: "info",
|
|
message,
|
|
fields,
|
|
tagColor: "#008FBF",
|
|
level: Level.Info,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Outputs a warning.
|
|
*/
|
|
public warn(fn: LogCallback): void;
|
|
public warn(message: string, ...fields: FieldArray): void;
|
|
public warn(message: LogCallback | string, ...fields: FieldArray): void {
|
|
this.handle({
|
|
type: "warn",
|
|
message,
|
|
fields,
|
|
tagColor: "#FF9D00",
|
|
level: Level.Warning,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Outputs a trace message.
|
|
*/
|
|
public trace(fn: LogCallback): void;
|
|
public trace(message: string, ...fields: FieldArray): void;
|
|
public trace(message: LogCallback | string, ...fields: FieldArray): void {
|
|
this.handle({
|
|
type: "trace",
|
|
message,
|
|
fields,
|
|
tagColor: "#888888",
|
|
level: Level.Trace,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Outputs a debug message.
|
|
*/
|
|
public debug(fn: LogCallback): void;
|
|
public debug(message: string, ...fields: FieldArray): void;
|
|
public debug(message: LogCallback | string, ...fields: FieldArray): void {
|
|
this.handle({
|
|
type: "debug",
|
|
message,
|
|
fields,
|
|
tagColor: "#84009E",
|
|
level: Level.Debug,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Outputs an error.
|
|
*/
|
|
public error(fn: LogCallback): void;
|
|
public error(message: string, ...fields: FieldArray): void;
|
|
public error(message: LogCallback | string, ...fields: FieldArray): void {
|
|
this.handle({
|
|
type: "error",
|
|
message,
|
|
fields,
|
|
tagColor: "#B00000",
|
|
level: Level.Error,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a sub-logger with a name.
|
|
* Each name is deterministically generated a color.
|
|
*/
|
|
public named(name: string, ...fields: FieldArray): Logger {
|
|
const l = new Logger(this._formatter, name, fields);
|
|
if (this.muted) {
|
|
l.mute();
|
|
}
|
|
|
|
return l;
|
|
}
|
|
|
|
/**
|
|
* Outputs a message.
|
|
*/
|
|
private handle(options: {
|
|
type: "trace" | "info" | "warn" | "debug" | "error";
|
|
message: string | LogCallback;
|
|
fields?: FieldArray;
|
|
level: Level;
|
|
tagColor: string;
|
|
}): void {
|
|
if (this.level > options.level || this.muted) {
|
|
return;
|
|
}
|
|
|
|
let passedFields = options.fields || [];
|
|
if (typeof options.message === "function") {
|
|
const values = options.message();
|
|
options.message = values.shift() as string;
|
|
passedFields = values as FieldArray;
|
|
}
|
|
|
|
const fields = (this.defaultFields
|
|
? passedFields.filter((f) => !!f).concat(this.defaultFields)
|
|
: passedFields.filter((f) => !!f)) as Array<Field<any>>; // tslint:disable-line no-any
|
|
|
|
const now = Date.now();
|
|
let times: Array<Field<Time>> = [];
|
|
const hasFields = fields && fields.length > 0;
|
|
if (hasFields) {
|
|
times = fields.filter((f) => f.value instanceof Time);
|
|
}
|
|
|
|
this._formatter.tag(options.type.toUpperCase(), options.tagColor);
|
|
if (this.name && this.nameColor) {
|
|
this._formatter.tag(this.name.toUpperCase(), this.nameColor);
|
|
}
|
|
this._formatter.push(options.message);
|
|
if (times.length > 0) {
|
|
times.forEach((time) => {
|
|
const diff = now - time.value.ms;
|
|
const expPer = diff / time.value.expected;
|
|
const min = 125 * (1 - expPer);
|
|
const max = 125 + min;
|
|
const green = expPer < 1 ? max : min;
|
|
const red = expPer >= 1 ? max : min;
|
|
this._formatter.push(` ${time.identifier}=`, "#3794ff");
|
|
this._formatter.push(`${diff}ms`, this.rgbToHex(red > 0 ? red : 0, green > 0 ? green : 0, 0));
|
|
});
|
|
}
|
|
|
|
// tslint:disable no-console
|
|
if (hasFields) {
|
|
this._formatter.fields(fields);
|
|
} else {
|
|
console.log(...this._formatter.flush());
|
|
}
|
|
// tslint:enable no-console
|
|
}
|
|
|
|
/**
|
|
* Hashes a string.
|
|
*/
|
|
private djb2(str: string): number {
|
|
let hash = 5381;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */
|
|
}
|
|
|
|
return hash;
|
|
}
|
|
|
|
/**
|
|
* Convert rgb to hex.
|
|
*/
|
|
private rgbToHex(r: number, g: number, b: number): string {
|
|
const integer = ((Math.round(r) & 0xFF) << 16)
|
|
+ ((Math.round(g) & 0xFF) << 8)
|
|
+ (Math.round(b) & 0xFF);
|
|
|
|
const str = integer.toString(16);
|
|
|
|
return "#" + "000000".substring(str.length) + str;
|
|
}
|
|
|
|
/**
|
|
* Generates a deterministic color from a string using hashing.
|
|
*/
|
|
private hashStringToColor(str: string): string {
|
|
const hash = this.djb2(str);
|
|
|
|
return this.rgbToHex(
|
|
(hash & 0xFF0000) >> 16,
|
|
(hash & 0x00FF00) >> 8,
|
|
hash & 0x0000FF,
|
|
);
|
|
}
|
|
}
|
|
|
|
export const logger = new Logger(
|
|
typeof process === "undefined" || typeof process.stdout === "undefined"
|
|
? new BrowserFormatter()
|
|
: new ServerFormatter(),
|
|
);
|