From df31d60af94a2919901e6cb637ac012d15437e23 Mon Sep 17 00:00:00 2001 From: zombieb Date: Sun, 4 Jan 2026 19:28:56 -0500 Subject: [PATCH] breaking change. it works better. --- README.md | 109 ++++++++++- benchmark/bun.ts | 64 ------ benchmark/deno-example.json | 227 --------------------- benchmark/deno.ts | 109 ----------- deno.json | 15 +- deno.lock | 22 +-- mod.ts | 381 ------------------------------------ src/mod.ts | 375 +++++++++++++++++++++++++++++++++++ src/types.ts | 74 +++++++ tests/bright.ts | 7 + tests/convert.ts | 35 +++- tests/global.ts | 2 +- tests/priority.ts | 28 +++ tests/time.ts | 8 + tests/timing.ts | 2 +- 15 files changed, 635 insertions(+), 823 deletions(-) delete mode 100644 benchmark/bun.ts delete mode 100644 benchmark/deno-example.json delete mode 100644 benchmark/deno.ts delete mode 100644 mod.ts create mode 100644 src/mod.ts create mode 100644 src/types.ts create mode 100644 tests/bright.ts create mode 100644 tests/priority.ts create mode 100644 tests/time.ts diff --git a/README.md b/README.md index 91e2bdd..25c4bf5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Undead Logging -Logging for stupid idiots like me +Opinionated logging * Pluggable event listeners * Per-source hushing, global log type hushing @@ -39,10 +39,10 @@ log.n("Something happened"); Disable a type of message globally: ```ts -import Logging, { MessageTypeVisibility } from "@proxnet/undead-logging"; +import Logging, { LoggingConfiguration } from "@proxnet/undead-logging"; // .. logging source "Main" is in the scope -MessageTypeVisibility.Error = false; +LoggingConfiguration.MessageTypeVisibility.Error = false; // somewhere else, could be a different script log.i("I am visible"); @@ -87,6 +87,8 @@ LoggingListeners.offmsg('basic', cb); ## Time display modes You can display four different formats for time: +* No time display + - Can be useful when running a service that already logs time and date, such as systemd services * UTC * Unix time (in milliseconds) * Local [process] time (in milliseconds, from the `performance` API) @@ -99,6 +101,8 @@ import { LoggingConfiguration, TimeFormat } from "@proxnet/undead-logging"; LoggingConfiguration.timeFormat = TimeFormat.Local; // or LoggingConfiguration.timeFormat = TimeFormat.Utc; +// or even +LoggingConfiguration.timeFormat = TimeFormat.None; // removes the time and date section of the log line entirely ``` ## (advanced) Logging timing @@ -118,10 +122,10 @@ You might also prefer to reset all sources to synchronous logging during app shu This can help debug issues with how your app closes. (see example below) ## Global resets -Every source is tracked by this module in a `Set`. +Every source is tracked by `LoggingConfiguration` in a `Set`. -You can reset every source's log timing or time format with `LoggingConfiguration.reset(something)`.
-The corresponding property will be updated on `LoggingConfiguration`: +You can reset every source's log timing or time format with `LoggingConfiguration.resetX`.
+The corresponding property will be updated on all instances of `Logging`: ```ts import Logging, { LoggingConfiguration, LogTiming } from "@proxnet/undead-logging"; @@ -149,7 +153,98 @@ Deno.addSignalListener('SIGINT', () => { }); ``` -## [Benchmarks](./BENCH.md) +## Class/Object Conversion +Any type of object can be given to a logging function. "[object Object]" may not always be desirable. + +You can automatically convert any object to a string of any format using a `Conversion`: + +```ts +class CustomClass { + + foo: string; + bar: number; + + constructor(foo: string, bar: number) { + this.foo = foo; + this.bar = bar; + } + +} + +LoggingConfiguration.addConversion({ + condition: arg => arg instanceof CustomClass, + converter: arg => `CustomClass; foo:${arg.foo}; bar:${arg.bar};` +}); + +/* + log an instance of `CustomClass`: + 2026-01-04T23:56:49.156Z ConvertTest [DEBUG] CustomClass; foo:baz; bar:39; +*/ +``` + +Promises are honored when using them in the `condition` and/or the `converter`. +
As such, you can use your own async in the conversion should you ever want to. + +Some classes and objects are converted by default, such as `Response` and `Request` standard APIs. +
They are demonstrated in `tests/convert.ts`, though they appear as: +``` +2026-01-04T23:56:48.965Z Web [INFO] GET http://example.com/?hello=world + key1: value1 + key2: value2 +2026-01-04T23:56:49.156Z Web [INFO] 200 OK https://example.com/ + age: 8217 + ... + server: cloudflare + vary: Accept-Encoding +``` + +You can remove these default conversions using `LoggingConfiguration.clearConversions()` before your program starts. + +### Converter priority +In semi-rare cases, you may want to prefer using some converters over others,
such as converting custom `Error`s rather than the base `Error` class. + +You can configure converter *priority* using `Conversion.priority`: + +```ts +class CustomError extends Error { + + someProperty: string + + constructor(someProp: string) { + super("Something went wrong."); + this.someProperty = someProp; + } + +} + +LoggingConfiguration.clearConversions(); +LoggingConfiguration.addConversion({ + condition: arg => arg instanceof CustomError, + converter: arg => `CustomError: someProperty:${arg.someProperty}; ${arg.stack || arg.message}`, + priority: -1 +}); +LoggingConfiguration.addConversion({ + condition: arg => arg instanceof Error, + converter: arg => `${arg.stack || arg.message}`, + priority: 1 +}); + +/* + CustomError at 1, Error at -1: + + 2026-01-05T00:09:18.176Z PriorityTest [INFO] CustomError: someProperty:'Hello World!'; Error: Something went wrong. + at file:///C:/Users/zombieb/undead-logging/tests/priority.ts:28:7 +*/ + +/* + CustomError at -1, Error at 1: + + 2026-01-05T00:09:34.040Z PriorityTest [INFO] Error: Something went wrong. + at file:///C:/Users/zombieb/undead-logging/tests/priority.ts:28:7 +*/ +``` + +Converters with numbers closer to `Infinity` have a higher priority. As such, you can choose your numerical layout for converter priority. ## Contributing create an account on gitea.proxnet.dev, fork, then PR \ No newline at end of file diff --git a/benchmark/bun.ts b/benchmark/bun.ts deleted file mode 100644 index 9f0e93b..0000000 --- a/benchmark/bun.ts +++ /dev/null @@ -1,64 +0,0 @@ -import chalk from "chalk"; -import Logging, { MessageType } from "../mod.ts"; - -interface BenchStats { - avg: number, - med: number, - rng: number, - min: number, - max: number, -} - -function createBench(n: number) { - const log = new Logging("Bench"); - const data: number[] = []; - - for (let i = 0; i < 30; i++) { - const last = performance.now(); - for (let i = 0; i < n; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - data.push(performance.now() - last); - } - - const sorted = [...data].sort((a, b) => a - b); - return { - n, - avg: data.reduce((sum, val) => sum + val, 0) / data.length, - med: sorted.length % 2 === 0 - ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 - : sorted[Math.floor(sorted.length / 2)], - rng: Math.max(...data) - Math.min(...data), - min: Math.min(...data), - max: Math.max(...data) - } as BenchStats; -} - -const benches = [ - createBench(1), - createBench(100), - createBench(1000), - createBench(10000), - createBench(100000), -]; - -function trimNumber(n: number) { - const split = n.toString().split('.'); - if (!split[1]) return n; - else if (split[1].length > 4) return `${split[0]}.${split[1].substring(0, 4)}`; - else if (split[1].length == 0) return n.toString(); - else return split.join('.'); -} - -for (const stats of benches) { - for (const value of Object.values(stats)) { - - const label = value < 1 ? 'µs' : 'ms'; - const val = value < 1 ? value * 1000 : value; - - const i = Object.values(stats).indexOf(value); - const key = Object.keys(stats).find((_val, index) => index == i); - - console.log(`${key}: ${trimNumber(val)}${key !== 'n' ? label : ''}`); - } - console.log(''); -} \ No newline at end of file diff --git a/benchmark/deno-example.json b/benchmark/deno-example.json deleted file mode 100644 index cce30f9..0000000 --- a/benchmark/deno-example.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "version": 1, - "runtime": "Deno/2.4.1 x86_64-pc-windows-msvc", - "cpu": "unknown", - "benches": [ - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-sync", - "name": "1 Log", - "baseline": false, - "results": [ - { - "ok": { - "n": 42, - "min": 1547.66, - "max": 1867.93, - "avg": 1596.3097619047621, - "p75": 1602.51, - "p99": 1867.93, - "p995": 1867.93, - "p999": 1867.93, - "highPrecision": false, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-sync", - "name": "100 Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 3227, - "min": 149200.0, - "max": 277100.0, - "avg": 155426.0, - "p75": 151700.0, - "p99": 230500.0, - "p995": 233300.0, - "p999": 250500.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-sync", - "name": "1k Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 332, - "min": 1492200.0, - "max": 2041200.0, - "avg": 1553763.0, - "p75": 1581400.0, - "p99": 1678900.0, - "p995": 1847700.0, - "p999": 2041200.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-sync", - "name": "10k Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 43, - "min": 15433100.0, - "max": 15875500.0, - "avg": 15512717.0, - "p75": 15516000.0, - "p99": 15875500.0, - "p995": 15875500.0, - "p999": 15875500.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-sync", - "name": "100k Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 14, - "min": 154448800.0, - "max": 162192300.0, - "avg": 156260422.0, - "p75": 156585200.0, - "p99": 162192300.0, - "p995": 162192300.0, - "p999": 162192300.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-deferred", - "name": "1 Log", - "baseline": false, - "results": [ - { - "ok": { - "n": 42, - "min": 1553.59, - "max": 1623.61, - "avg": 1581.7592857142856, - "p75": 1594.76, - "p99": 1623.61, - "p995": 1623.61, - "p999": 1623.61, - "highPrecision": false, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-deferred", - "name": "100 Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 3205, - "min": 148800.0, - "max": 357900.0, - "avg": 156507.0, - "p75": 152200.0, - "p99": 233300.0, - "p995": 241400.0, - "p999": 273500.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-deferred", - "name": "1k Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 330, - "min": 1504500.0, - "max": 1744900.0, - "avg": 1564083.0, - "p75": 1595100.0, - "p99": 1668200.0, - "p995": 1732400.0, - "p999": 1744900.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-deferred", - "name": "10k Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 43, - "min": 15435800.0, - "max": 15980800.0, - "avg": 15641247.0, - "p75": 15771900.0, - "p99": 15980800.0, - "p995": 15980800.0, - "p999": 15980800.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - }, - { - "origin": "file:///path/to/undead-logging/benchmark/deno.ts", - "group": "timing-deferred", - "name": "100k Logs", - "baseline": false, - "results": [ - { - "ok": { - "n": 14, - "min": 154775900.0, - "max": 159622500.0, - "avg": 156075222.0, - "p75": 156708400.0, - "p99": 159622500.0, - "p995": 159622500.0, - "p999": 159622500.0, - "highPrecision": true, - "usedExplicitTimers": false - } - } - ] - } - ] -} \ No newline at end of file diff --git a/benchmark/deno.ts b/benchmark/deno.ts deleted file mode 100644 index 74127c2..0000000 --- a/benchmark/deno.ts +++ /dev/null @@ -1,109 +0,0 @@ -import Logging, { LogTiming, MessageType } from "@proxnet/undead-logging"; -import chalk from "chalk"; - -// SYNC - -Deno.bench({ - name: "1 Log", - group: "timing-sync", - fn: () => { - const log = new Logging("Bench"); - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "100 Logs", - group: "timing-sync", - fn: () => { - const log = new Logging("Bench"); - for (let i = 0; i < 100; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "1k Logs", - group: "timing-sync", - fn: () => { - const log = new Logging("Bench"); - for (let i = 0; i < 1_000; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "10k Logs", - group: "timing-sync", - fn: () => { - const log = new Logging("Bench"); - for (let i = 0; i < 10_000; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "100k Logs", - group: "timing-sync", - fn: () => { - const log = new Logging("Bench"); - log.logTiming = LogTiming.Deferred; - for (let i = 0; i < 100_000; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -// DEFERRED - -Deno.bench({ - name: "1 Log", - group: "timing-deferred", - fn: () => { - const log = new Logging("Bench"); - log.logTiming = LogTiming.Deferred; - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "100 Logs", - group: "timing-deferred", - fn: () => { - const log = new Logging("Bench"); - log.logTiming = LogTiming.Deferred; - for (let i = 0; i < 100; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "1k Logs", - group: "timing-deferred", - fn: () => { - const log = new Logging("Bench"); - log.logTiming = LogTiming.Deferred; - for (let i = 0; i < 1_000; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "10k Logs", - group: "timing-deferred", - fn: () => { - const log = new Logging("Bench"); - log.logTiming = LogTiming.Deferred; - for (let i = 0; i < 10_000; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); - -Deno.bench({ - name: "100k Logs", - group: "timing-deferred", - fn: () => { - const log = new Logging("Bench"); - for (let i = 0; i < 100_000; i++) - log.bench(MessageType.Info, chalk.black, 'a'); - } -}); \ No newline at end of file diff --git a/deno.json b/deno.json index 30e18c0..668746a 100644 --- a/deno.json +++ b/deno.json @@ -6,14 +6,13 @@ "bench": "deno bench -A" }, "imports": { - "@std/assert": "jsr:@std/assert@1", - "chalk": "npm:chalk@^5.3.0" + "@neabyte/deno-ansi": "jsr:@neabyte/deno-ansi@^0.1.0" }, - "exports": "./mod.ts", - "version": "1.5.0", + "exports": { + ".": "./src/mod.ts", + "./types": "./src/types.ts" + }, + "version": "1.6.0", "name": "@proxnet/undead-logging", - "license": "MIT", - "bench": { - "include": ["./benchmark/deno.ts"] - } + "license": "MIT" } diff --git a/deno.lock b/deno.lock index a4cb8a4..58c4004 100644 --- a/deno.lock +++ b/deno.lock @@ -1,20 +1,12 @@ { "version": "5", "specifiers": { - "jsr:@std/assert@1": "1.0.13", - "jsr:@std/internal@^1.0.6": "1.0.9", - "npm:@types/node@*": "22.15.15", - "npm:chalk@^5.3.0": "5.4.1" + "jsr:@neabyte/deno-ansi@0.1": "0.1.0", + "npm:@types/node@*": "22.15.15" }, "jsr": { - "@std/assert@1.0.13": { - "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.9": { - "integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8" + "@neabyte/deno-ansi@0.1.0": { + "integrity": "3affddb394ce77feb2c40b3d9f8ae3c2045f8bc721038c2d6610f09db37edf72" } }, "npm": { @@ -24,17 +16,13 @@ "undici-types" ] }, - "chalk@5.4.1": { - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==" - }, "undici-types@6.21.0": { "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" } }, "workspace": { "dependencies": [ - "jsr:@std/assert@1", - "npm:chalk@^5.3.0" + "jsr:@neabyte/deno-ansi@0.1" ] } } diff --git a/mod.ts b/mod.ts deleted file mode 100644 index 4253365..0000000 --- a/mod.ts +++ /dev/null @@ -1,381 +0,0 @@ -import chalk from "chalk"; -import { setImmediate } from "node:timers"; -import process from "node:process"; - -type Message = unknown; -type ChalkFunction = (...msgs: Message[]) => string; -type PrimitiveItems = Message[]; - -interface UnknownConversion { - condition: (arg: Message) => boolean; - converter: (arg: T) => string; -} - -/** How would you like your logs served? */ -interface LoggingOptions { - logTiming?: LogTiming, - timeFormat?: TimeFormat, - bright?: boolean, - silent?: boolean -} - -/** Control all log sources from this module */ -const sources: Set = new Set(); - -/** The first log message across all sources will not contain carriage return + newline control codes */ -let first = true; - -/** - * A source for pretty and cool and fun logging - */ -class Logging { - - /** Can I be seen by the console and listeners? */ - visible: boolean - /** What is my name? */ - source: string - /** Should I use bright colors over dull ones? */ - bright: boolean = true; - - /** Control when logs are handled for this source - similar (defaults to) `LoggingConfiguration.logTiming` */ - logTiming: LogTiming = LoggingConfiguration.logTiming; - /** Control how time is displayed for this source - similar (defaults to) `LoggingConfiguration.timeFormat` */ - timeFormat: TimeFormat = LoggingConfiguration.timeFormat; - - /** - * Create a logging source - * ```ts - * const log = new Logging("Main"); - * - * log.i("Hello World!"); - * ``` - * @param source Module identifier. Used in every line to identify the module that sent the message. - * @param silent Set to false to log a message when the logger instantiates. May be useful when debugging. - * @returns A source for logging messages to the console (stdout). Functions for info, warnings, errors, debug statements, and network events are provided and have shorthands. - */ - constructor(source: string, options?: LoggingOptions) { - this.visible = true; - this.source = source; - - if (options) { - if (options.bright) this.bright = options.bright; - if (typeof options.silent == 'boolean' && !options.silent) this.info(`Instantiated logging for ${this.source}`); - if (options.logTiming) this.logTiming = options.logTiming; - if (options.timeFormat) this.timeFormat = options.timeFormat; - } - - sources.add(this); - } - - #conversions(...msgs: PrimitiveItems) { - const conversions = [ - { - condition: arg => arg instanceof Error, - converter: arg => arg.stack || arg.message - } as UnknownConversion, - { - condition: arg => arg == null, - converter: arg => JSON.stringify(arg) - } as UnknownConversion, - { - condition: arg => arg == undefined, - converter: _arg => 'undefined' - } as UnknownConversion, - { - condition: arg => arg instanceof Response, - converter: arg => { - try { - const url = arg.url.toString().length == 0 ? '(unknown origin) ' : new URL(arg.url); - const statusText = arg.statusText.length > 0 ? `${arg.statusText} ` : ''; - - const shouldNewline = Array.from(arg.headers.keys()).length > 0; - const entries = Array.from(arg.headers.entries()); - return ( - `${arg.status} ${statusText}${url}${shouldNewline ? '\n ' : ''}` + - entries.map(val => `${val[0]}: ${val[1]}`).join(`\n `) - ); - } catch { - return String(arg); - } - } - } as UnknownConversion, - { - condition: arg => arg instanceof Request, - converter: arg => { - try { - const url = new URL(arg.url); - const shouldNewline = Array.from(arg.headers.keys()).length > 0; - const entries = Array.from(arg.headers.entries()); - return `${arg.method} ${url}${shouldNewline ? '\n ' : ''}${entries.map(val => `${val[0]}: ${val[1]}`).join(`\n `)}`; - } catch { - return String(arg); - } - } - } as UnknownConversion, - { - condition: arg => typeof arg == 'object', - converter: arg => JSON.stringify(arg) - } as UnknownConversion, - ]; - function convertToString(arg: Message) { - for (const conversion of conversions) - if (conversion.condition(arg)) - return (conversion.converter as (arg: unknown) => string)(arg); - // gpt-4o idea cuz my brain has the `stupid` type - - return String(arg); // fallback - } - return msgs.map(val => convertToString(val)).join(' '); - } - #typeStr(type: MessageType) { - switch (type) { - case MessageType.Info: - return '[INFO]'; - case MessageType.Warn: - return '[WARN]'; - case MessageType.Error: - return '[ERROR]'; - case MessageType.Debug: - return '[DEBUG]'; - case MessageType.Network: - return '[NETWORK]'; - default: - return '[UNKNOWN]'; - } - } - #timeStr(time: Date) { - switch (this.timeFormat) { - case TimeFormat.Unix: - return time.getTime(); - case TimeFormat.Local: - return performance.now(); - case TimeFormat.RoundedLocal: - return Math.round(performance.now()); - default: - return time.toISOString(); - } - } - - /** - * Don't use this unless you're benchmarking. - * - * Constructs a message. Does not do anything with it, or even return it. - * - * Logging messages during [Deno] benchmarks causes problems. - */ - bench(type: MessageType, chalkColor: ChalkFunction, ...msgs: PrimitiveItems) { - `${chalk.gray(`${this.#timeStr(new Date())} `)} ${chalkColor(chalk.inverse(`${this.source} ${this.#typeStr(type)}`) + ` ${this.#conversions(...msgs)}`)}`; - } - #log(type: MessageType, chalkColor: ChalkFunction, ...msgs: PrimitiveItems) { - if (!this.visible) return; - const func = () => { - - const time = new Date(); - - const str = this.#conversions(...msgs); - const msg = `${chalk.gray(this.#timeStr(time))} ${chalkColor(chalk.inverse(`${this.source} ${this.#typeStr(type)}`) + ` ${str}`)}` - - process.stdout.write(`${first ? '' : '\r\n'}${msg}`); - // I don't know if promise-ifying this helps or not. - // deno-lint-ignore require-await - (async () => { - first = false; - })(); - - try { - LoggingListeners.emit('basic', msg); - LoggingListeners.emit('type', str, type, this.source, time); - } catch (err) { - process.stderr.write(`\r\n(FALLBACK ERROR) Callback failed! Stack: ${(err as Error).stack}`); - } - } - - switch (this.logTiming) { - case LogTiming.Sync: - func(); - break; - case LogTiming.Deferred: - setImmediate(func); - break; - case LogTiming.Microtask: - if (typeof queueMicrotask == 'function') - queueMicrotask(func); - else - setTimeout(func, 0); // fallback - break; - default: - func(); - break; - } - } - - /** - * - * @param type The kind of message to log - * @param msgs Your message(s) - */ - log(type: MessageType, ...msgs: PrimitiveItems) { - switch (type) { - case MessageType.Info: - this.info(...msgs); - break; - case MessageType.Warn: - this.warn(...msgs); - break; - case MessageType.Error: - this.error(...msgs); - break; - case MessageType.Debug: - this.debug(...msgs); - break; - case MessageType.Network: - this.network(...msgs); - break; - default: - this.info(...msgs); - break; - } - } - - /** Logging Function */ - i(...msgs: PrimitiveItems) { this.info(...msgs); } - /** Logging Function */ - info(...msgs: PrimitiveItems) { - if (!MessageTypeVisibility.Info) return; - this.#log(MessageType.Info, chalk.whiteBright, ...msgs); - } - - /** Logging Function */ - w(...msgs: PrimitiveItems) { this.warn(...msgs); } - /** Logging Function */ - warn(...msgs: PrimitiveItems) { - if (!MessageTypeVisibility.Warn) return; - this.#log(MessageType.Warn, chalk.yellowBright, ...msgs); - } - - /** Logging Function */ - e(...msgs: PrimitiveItems) { this.error(...msgs); } - /** Logging Function */ - error(...msgs: PrimitiveItems) { - if (!MessageTypeVisibility.Error) return; - this.#log(MessageType.Error, chalk.redBright, ...msgs); - } - - /** Logging Function */ - d(...msgs: PrimitiveItems) { this.debug(...msgs); } - /** Logging Function */ - debug(...msgs: PrimitiveItems) { - if (!MessageTypeVisibility.Debug) return; - this.#log(MessageType.Debug, chalk.greenBright, ...msgs); - } - - /** Logging Function */ - n(...msgs: PrimitiveItems) { this.network(...msgs); } - /** Logging Function */ - network(...msgs: PrimitiveItems) { - if (!MessageTypeVisibility.Network) return; - this.#log(MessageType.Network, chalk.cyanBright, ...msgs); - } - -} - -/** Useful for conditional logging with the `log` function */ -enum MessageType { - Info, - Warn, - Error, - Debug, - Network -} - -/** Enable/disable logging of certain message types across all logging sources */ -const MessageTypeVisibility = { - Info: true, - Warn: true, - Error: true, - Debug: true, - Network: true -} - -// 'On Message' event listeners -type BasicListener = (msg: string) => unknown; -type TypeListener = (msg: string, type: MessageType, source: string, time: Date) => unknown; -type Listener = BasicListener | TypeListener; -type ListenerType = 'basic' | 'type'; - -const basicListeners = new Set(); -const typeListeners = new Set(); - -class ListenersBase { - /** Register listener callback */ - onmsg(type: 'basic', cb: BasicListener): void - onmsg(type: 'type', cb: TypeListener): void - onmsg(type: ListenerType, cb: Listener) { - if (type == 'basic') basicListeners.add(cb); - else if (type == 'type') typeListeners.add(cb); - } - - /** Remove listener callback */ - offmsg(type: 'basic', cb: BasicListener): void - offmsg(type: 'type', cb: TypeListener): void - offmsg(type: ListenerType, cb: Listener) { - if (type == 'basic') basicListeners.delete(cb); - else if (type == 'type') typeListeners.delete(cb); - } - - /** Emit a log line. Usually not needed outside this module. */ - emit(type: 'basic', msg: string, msgtype?: MessageType, source?: string, time?: Date): void - emit(type: 'type', msg: string, msgtype: MessageType, source: string, time: Date): void - emit(type: ListenerType, msg: string, msgtype: MessageType, source: string, time: Date) { - if (type == 'basic') - for (const cb of basicListeners) (cb as BasicListener)(msg); - if (type == 'type') - for (const cb of typeListeners) (cb as TypeListener)(msg, msgtype, source, time); - } -} -/** - * Add/remove callbacks: run when something is logged anywhere - */ -const LoggingListeners: ListenersBase = new ListenersBase(); - -/** - * Specify when, in/around the event loop, logs are sent - */ -enum LogTiming { - Sync, - Deferred, - Microtask -} -/** - * Specify the format that loggers use to display time - */ -enum TimeFormat { - Utc, - Unix, - Local, - RoundedLocal -} - -class LoggingConfigurationBase { - - #logTiming: LogTiming = LogTiming.Sync; - - #timeFormat: TimeFormat = TimeFormat.Utc; - - get timeFormat(): TimeFormat { return this.#timeFormat } - set timeFormat(data: TimeFormat) { this.#timeFormat = data } - - get logTiming(): LogTiming { return this.#logTiming; } - set logTiming(data: LogTiming) { this.#logTiming = data } - - set resetTimeFormat(data: TimeFormat) { for (const source of sources.values()) source.timeFormat = data; this.#timeFormat = data; } - set resetLogTiming(data: LogTiming) { for (const source of sources.values()) source.logTiming = data; this.#logTiming = data; } - -} -/** - * Configure time display format and log timing here - */ -const LoggingConfiguration: LoggingConfigurationBase = new LoggingConfigurationBase(); - -export { MessageType, TimeFormat, LogTiming, MessageTypeVisibility, LoggingListeners, LoggingConfiguration }; -export default Logging; \ No newline at end of file diff --git a/src/mod.ts b/src/mod.ts new file mode 100644 index 0000000..47ba5c2 --- /dev/null +++ b/src/mod.ts @@ -0,0 +1,375 @@ +import { setImmediate } from "node:timers"; +import process from "node:process"; +import type { Conversion, LoggingOptions, Message } from "./types.ts"; +import { TimeFormat, MessageType, LogTiming } from "./types.ts"; +import * as ANSI from "@neabyte/deno-ansi"; + +/** + * A source for pretty and cool and fun logging + */ +class Logging { + + /** + * Gets the line ending for this platform. + */ + static getNewline() { + return Deno.build.os === 'windows' ? '\r\n' : '\n' + } + + static timeStr(format: TimeFormat, time: Date) { + switch (format) { + case TimeFormat.None: + return ''; + case TimeFormat.Unix: + return `${time.getTime()} `; + case TimeFormat.Local: + return `${performance.now()} `; + case TimeFormat.RoundedLocal: + return `${Math.round(performance.now())} `; + case TimeFormat.Utc: + return `${time.toISOString()} `; + } + } + static typeStr(type: MessageType) { + switch (type) { + case MessageType.Info: + return '[INFO]'; + case MessageType.Warn: + return '[WARN]'; + case MessageType.Error: + return '[ERROR]'; + case MessageType.Debug: + return '[DEBUG]'; + case MessageType.Network: + return '[NETWORK]'; + default: + return '[UNKNOWN]'; + } + } + + /** Can I be seen by the console and listeners? */ + visible: boolean + /** What is my name? */ + source: string + /** Should I use bright colors over dull ones? */ + bright: boolean = true; + /** Delimiter between message arguments, default to space */ + join: string = ' '; + + /** Control when logs are handled for this source - similar (defaults to) `LoggingConfiguration.logTiming` */ + logTiming: LogTiming = LoggingConfiguration.logTiming; + /** Control how time is displayed for this source - similar (defaults to) `LoggingConfiguration.timeFormat` */ + timeFormat: TimeFormat = LoggingConfiguration.timeFormat; + + /** + * Create a logging source + * ```ts + * const log = new Logging("Main"); + * + * log.i("Hello World!"); + * ``` + * @param source Module identifier. Used in every line to identify the module that sent the message. + * @param silent Set to false to log a message when the logger instantiates. May be useful when debugging. + * @returns A source for logging messages to the console (stdout). Functions for info, warnings, errors, debug statements, and network events are provided and have shorthands. + */ + constructor(source: string, options?: LoggingOptions) { + this.visible = true; + this.source = source; + + if (options) { + if (options.bright) this.bright = options.bright; + if (typeof options.silent == 'boolean' && !options.silent) this.info(`Instantiated logging for ${this.source}`); + if (options.logTiming) this.logTiming = options.logTiming; + if (options.timeFormat) this.timeFormat = options.timeFormat; + } + + LoggingConfiguration.sources.add(this); + } + + async #conversions(...msgs: Message[]) { + return await Promise.all(msgs.map(async msg => { + for (const converter of LoggingConfiguration.getConversions() + .values() + .toArray() + .sort((a, b) => -(typeof a.priority == 'undefined' ? 0 : a.priority) + (typeof b.priority == 'undefined' ? 0 : b.priority)) + ) + // condition may or may not be async + if (await Promise.resolve(converter.condition(msg))) return await converter.converter(msg); + + return String(msg); // fallback to JS string + })); + } + + #log(type: MessageType, ...msgs: Message[]) { + if (!this.visible) return; + const func = async () => { + + const time = new Date(); + + const str = (await this.#conversions(...msgs)).join(this.join); + + const timeFormatted = ANSI.Colors.dim(Logging.timeStr(this.timeFormat, time)); + + let color: number; + switch (type) { + case MessageType.Info: + color = this.bright ? ANSI.Colors.BRIGHT_WHITE : ANSI.Colors.WHITE; + break; + case MessageType.Warn: + color = this.bright ? ANSI.Colors.BRIGHT_YELLOW : ANSI.Colors.YELLOW; + break; + case MessageType.Error: + color = this.bright ? ANSI.Colors.BRIGHT_RED : ANSI.Colors.RED; + break; + case MessageType.Debug: + color = this.bright ? ANSI.Colors.BRIGHT_GREEN : ANSI.Colors.GREEN; + break; + case MessageType.Network: + color = this.bright ? ANSI.Colors.BRIGHT_CYAN : ANSI.Colors.CYAN; + break; + } + const targetStr = `${ANSI.Colors.inverse(ANSI.Colors.fg(`${this.source} ${Logging.typeStr(type)}`, color))} ${ANSI.Colors.fg(str, color)}`; + const msg = `${timeFormatted}${targetStr}`; + + process.stdout.write(`${msg}${Logging.getNewline()}`); + + try { + LoggingListeners.emit('basic', msg); + LoggingListeners.emit('type', str, type, this.source, time); + } catch (err) { + process.stderr.write(`(FALLBACK ERROR) Callback failed! Stack: ${(err as Error).stack}`); + } + } + + switch (this.logTiming) { + case LogTiming.Sync: + func(); + break; + case LogTiming.Deferred: + setImmediate(func); + break; + case LogTiming.Microtask: + if (typeof queueMicrotask == 'function') + queueMicrotask(func); + else + setTimeout(func, 0); // fallback + break; + default: + func(); + break; + } + } + + /** + * + * @param type The kind of message to log + * @param msgs Your message(s) + */ + log(type: MessageType, ...msgs: Message[]) { + switch (type) { + case MessageType.Info: + this.info(...msgs); + break; + case MessageType.Warn: + this.warn(...msgs); + break; + case MessageType.Error: + this.error(...msgs); + break; + case MessageType.Debug: + this.debug(...msgs); + break; + case MessageType.Network: + this.network(...msgs); + break; + default: + this.info(...msgs); + break; + } + } + + /** Logging Function */ + i(...msgs: Message[]) { this.info(...msgs); } + /** Logging Function */ + info(...msgs: Message[]) { + if (!LoggingConfiguration.MessageTypeVisibility.Info) return; + this.#log(MessageType.Info, ...msgs); + } + + /** Logging Function */ + w(...msgs: Message[]) { this.warn(...msgs); } + /** Logging Function */ + warn(...msgs: Message[]) { + if (!LoggingConfiguration.MessageTypeVisibility.Warn) return; + this.#log(MessageType.Warn, ...msgs); + } + + /** Logging Function */ + e(...msgs: Message[]) { this.error(...msgs); } + /** Logging Function */ + error(...msgs: Message[]) { + if (!LoggingConfiguration.MessageTypeVisibility.Error) return; + this.#log(MessageType.Error, ...msgs); + } + + /** Logging Function */ + d(...msgs: Message[]) { this.debug(...msgs); } + /** Logging Function */ + debug(...msgs: Message[]) { + if (!LoggingConfiguration.MessageTypeVisibility.Debug) return; + this.#log(MessageType.Debug, ...msgs); + } + + /** Logging Function */ + n(...msgs: Message[]) { this.network(...msgs); } + /** Logging Function */ + network(...msgs: Message[]) { + if (!LoggingConfiguration.MessageTypeVisibility.Network) return; + this.#log(MessageType.Network, ...msgs); + } + +} + +// 'On Message' event listeners +type BasicListener = (msg: string) => unknown; +type TypeListener = (msg: string, type: MessageType, source: string, time: Date) => unknown; +type Listener = BasicListener | TypeListener; +type ListenerType = 'basic' | 'type'; + +export class ListenersBase { + + listeners = new Set<{ cb: Listener, type: ListenerType }>(); + + /** Register listener callback */ + onmsg(type: 'basic', cb: BasicListener): void + onmsg(type: 'type', cb: TypeListener): void + onmsg(type: ListenerType, cb: Listener) { + if (type == 'basic') this.listeners.add({ cb, type: "basic" }); + else if (type == 'type') this.listeners.add({ cb, type: "type" }); + } + + /** Remove listener callback */ + offmsg(type: 'basic', cb: BasicListener): void + offmsg(type: 'type', cb: TypeListener): void + offmsg(type: ListenerType, cb: Listener) { + if (type == 'basic') this.listeners.delete({ cb, type: "basic" }); + else if (type == 'type') this.listeners.delete({ cb, type: "type" }); + } + + /** Emit a log line */ + emit(type: 'basic', msg: string): void; + emit(type: 'type', msg: string, msgtype: MessageType, source: string, time: Date): void; + emit(type: ListenerType, msg: string, msgtype?: MessageType, source?: string, time?: Date) { + if (type == 'basic') + for (const cb of this.listeners.values().filter(listener => listener.type == 'basic')) (cb.cb as BasicListener)(msg); + if (type == 'type') { + if ([msgtype, source, time].some(val => typeof val == 'undefined')) throw new Error("Missing arguments for type log emission"); + for (const cb of this.listeners.values().filter(listener => listener.type == "type")) (cb.cb as TypeListener)(msg, msgtype!, source!, time!); + } + } + +} +/** + * Add/remove callbacks: run when something is logged anywhere + */ +const LoggingListeners: ListenersBase = new ListenersBase(); + +class LoggingConfigurationBase { + + #logTiming: LogTiming = LogTiming.Sync; + + #timeFormat: TimeFormat = TimeFormat.Utc; + + sources: Set = new Set(); + + // deno-lint-ignore no-explicit-any + #conversions: Set> = new Set(); // ¯\_(ツ)_/¯ + + constructor() { + + this.addConversion({ + condition: arg => arg instanceof Error, + converter: arg => arg.stack || arg.message + }); + this.addConversion({ + condition: arg => arg == null, + converter: () => `null` + }); + this.addConversion({ + condition: arg => arg == undefined, + converter: () => `undefined` + }); + this.addConversion({ + condition: arg => arg instanceof Response, + converter: arg => { + try { + const url = arg.url.toString().length == 0 ? '(unknown origin) ' : new URL(arg.url); + const statusText = arg.statusText.length > 0 ? `${arg.statusText} ` : ''; + + const shouldNewline = Array.from(arg.headers.keys()).length > 0; + const entries = Array.from(arg.headers.entries()); + return ( + `${arg.status} ${statusText}${url}${shouldNewline ? '\n ' : ''}` + + entries.map(val => `${val[0]}: ${val[1]}`).join(`\n `) + ); + } catch { + return String(arg); + } + } + }); + this.addConversion({ + condition: arg => arg instanceof Request, + converter: arg => { + try { + const url = new URL(arg.url); + const shouldNewline = Array.from(arg.headers.keys()).length > 0; + const entries = Array.from(arg.headers.entries()); + return `${arg.method} ${url}${shouldNewline ? '\n ' : ''}${entries.map(val => `${val[0]}: ${val[1]}`).join(`\n `)}`; + } catch { + return String(arg); + } + } + }); + + } + + clearConversions() { + this.#conversions.clear(); + } + addConversion(con: Conversion) { + this.#conversions.add(con); + } + getConversions() { + return this.#conversions; + } + + getAllLoggers() { + return this.sources.values(); + } + + get timeFormat(): TimeFormat { return this.#timeFormat } + set timeFormat(data: TimeFormat) { this.#timeFormat = data } + + get logTiming(): LogTiming { return this.#logTiming; } + set logTiming(data: LogTiming) { this.#logTiming = data } + + set resetTimeFormat(data: TimeFormat) { for (const source of this.sources.values()) source.timeFormat = data; this.#timeFormat = data; } + set resetLogTiming(data: LogTiming) { for (const source of this.sources.values()) source.logTiming = data; this.#logTiming = data; } + + /** Enable/disable logging of certain message types across all logging sources */ + MessageTypeVisibility = { + Info: true, + Warn: true, + Error: true, + Debug: true, + Network: true + } + +} +/** + * Configure global logging configuration, such as converters and visibility + */ +const LoggingConfiguration: LoggingConfigurationBase = new LoggingConfigurationBase(); + +export { MessageType, TimeFormat, LogTiming, LoggingListeners, LoggingConfiguration }; +export default Logging; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cff8d93 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,74 @@ +/** + * Specify when, in/around the event loop, logs are sent + */ +export enum LogTiming { + Sync, + Deferred, + Microtask +} +/** + * Specify the format that loggers use to display time + */ +export enum TimeFormat { + None, + Utc, + Unix, + Local, + RoundedLocal +} + +// 'On Message' event listeners +export type BasicListener = (msg: string) => unknown; +export type TypeListener = (msg: string, type: MessageType, source: string, time: Date) => unknown; +export type Listener = BasicListener | TypeListener; +export type ListenerType = 'basic' | 'type'; + +/** Useful for conditional logging with the `log` function */ +export enum MessageType { + Info, + Warn, + Error, + Debug, + Network +} + +/** Type for any object given to a logging function. Not really much different from `any`. */ +export type Message = null | undefined | boolean | number | bigint | string | symbol | object; + +/** Convert objects of any certain kind of value to a string. */ +export interface Conversion { + /** + * Given a message value, determine whether this converter is capable of converting to a string or not. + * + * Promises are honored. + */ + condition: (arg: unknown) => Promise | boolean, + /** + * Runtime type of object is guaranteed when the condition passes. + * + * Promises are honored. + */ + converter: (arg: T) => Promise | string, + /** + * Defaults to 0. + * + * Since converters are checked sequentially in order of priority, generic converters should have higher priority. + * + * Converters with a priority closer to 0 have a higher priority. + * + * For example, when: + * * a converter that converts `Error` with priority 39, and + * * a converter that converts `CustomError` with priority 38, + * + * priority 1 will be resolved first to determine converter compatibility with the message value. + */ + priority?: number +} + +/** How would you like your logs served? */ +export interface LoggingOptions { + logTiming?: LogTiming, + timeFormat?: TimeFormat, + bright?: boolean, + silent?: boolean +} \ No newline at end of file diff --git a/tests/bright.ts b/tests/bright.ts new file mode 100644 index 0000000..bf95cfb --- /dev/null +++ b/tests/bright.ts @@ -0,0 +1,7 @@ +import Logging from "../src/mod.ts"; + +const log = new Logging("NotBright"); + +log.bright = false; + +log.i("Not Bright"); \ No newline at end of file diff --git a/tests/convert.ts b/tests/convert.ts index 0ba0bac..a4c9d34 100644 --- a/tests/convert.ts +++ b/tests/convert.ts @@ -1,16 +1,14 @@ -import Logging, { LoggingListeners } from "../mod.ts"; -import process from "node:process"; +import Logging, { LoggingConfiguration, LoggingListeners } from "../src/mod.ts"; -const debug = process.argv[process.argv.length - 1] == 'true'; +const debug = Deno.args[Deno.args.length - 1] === 'true'; console.debug(`Debug mode: ${debug}`); -const changeTimeFormat = process.argv[process.argv.length - 2] == 'true'; -console.debug(`changeTimeFormat: ${changeTimeFormat}`); + if (debug) { LoggingListeners.onmsg('basic', msg => { - console.debug(`\r\n[d] ${msg}`); + console.debug(`[d] ${msg}`); }); LoggingListeners.onmsg('type', (msg, type, source, time) => { - console.debug(`[D] M:'${msg}' T:${type} S:'${source}' TM:${time.getTime()}`); + console.debug(`[D] M:'${msg}' T:${type} S:'${source}' TM:${time.getTime()}${Logging.getNewline()}`); }); } @@ -18,4 +16,25 @@ const webLog = new Logging("Web"); webLog.d(`Following is a Request`); webLog.i(new Request('http://example.com?hello=world', { headers: { 'key1': 'value1', 'key2': 'value2' }})); webLog.d(`Following is a Response`); -webLog.i(await fetch('https://example.com')); \ No newline at end of file +webLog.i(await fetch('https://example.com')); + +class CustomClass { + + foo: string; + bar: number; + + constructor(foo: string, bar: number) { + this.foo = foo; + this.bar = bar; + } + +} + +LoggingConfiguration.addConversion({ + condition: arg => arg instanceof CustomClass, + converter: arg => `CustomClass; foo:${arg.foo}; bar:${arg.bar};` +}); + +const log = new Logging("ConvertTest"); + +log.d(new CustomClass("baz", 39)); \ No newline at end of file diff --git a/tests/global.ts b/tests/global.ts index 13d8213..28e7fee 100644 --- a/tests/global.ts +++ b/tests/global.ts @@ -1,4 +1,4 @@ -import Logging, { LoggingConfiguration, LogTiming } from "@proxnet/undead-logging"; +import Logging, { LoggingConfiguration, LogTiming } from "./../src/mod.ts"; const log = new Logging("Logger"); diff --git a/tests/priority.ts b/tests/priority.ts new file mode 100644 index 0000000..e337b1f --- /dev/null +++ b/tests/priority.ts @@ -0,0 +1,28 @@ +import Logging, { LoggingConfiguration } from "@proxnet/undead-logging"; + +class CustomError extends Error { + + someProperty: string + + constructor(someProp: string) { + super("Something went wrong."); + this.someProperty = someProp; + } + +} + +LoggingConfiguration.clearConversions(); +LoggingConfiguration.addConversion({ + condition: arg => arg instanceof CustomError, + converter: arg => `CustomError: someProperty:${arg.someProperty}; ${arg.stack || arg.message}`, + priority: -1 +}); +LoggingConfiguration.addConversion({ + condition: arg => arg instanceof Error, + converter: arg => `Error: ${arg.stack || arg.message}`, + priority: 1 +}); + +const log = new Logging("PriorityTest"); + +log.i(new CustomError("'Hello World!'")); \ No newline at end of file diff --git a/tests/time.ts b/tests/time.ts new file mode 100644 index 0000000..74c8c79 --- /dev/null +++ b/tests/time.ts @@ -0,0 +1,8 @@ +import Logging, { LoggingConfiguration } from "../src/mod.ts"; +import { TimeFormat } from "../src/types.ts"; + +LoggingConfiguration.timeFormat = TimeFormat.None; + +const log = new Logging("NoDate"); + +log.i('No Date'); \ No newline at end of file diff --git a/tests/timing.ts b/tests/timing.ts index 57a7075..9b5e87a 100644 --- a/tests/timing.ts +++ b/tests/timing.ts @@ -1,7 +1,7 @@ import Logging, { LoggingConfiguration, LogTiming, -} from "../mod.ts"; +} from "./../src/mod.ts"; LoggingConfiguration.logTiming = LogTiming.Microtask; // high-priority logs