diff --git a/.xo-config.js b/.xo-config.js index 467ba10..f92d991 100644 --- a/.xo-config.js +++ b/.xo-config.js @@ -8,5 +8,24 @@ module.exports = { 'n/prefer-global/process': 'off', 'prefer-object-spread': 'off', 'unicorn/prefer-includes': 'off' - } + }, + overrides: [ + { + files: ['**/*.d.ts'], + rules: { + 'no-unused-vars': 'off', + '@typescript-eslint/naming-convention': 'off', + 'no-redeclare': 'off', + '@typescript-eslint/no-redeclare': 'off' + } + }, + { + files: ['**/*.test-d.ts'], + rules: { + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-confusing-void-expression': 'off', // Conflicts with `expectError` assertion. + '@typescript-eslint/no-unsafe-assignment': 'off' + } + } + ] }; diff --git a/package.json b/package.json index 87da4b1..ccc60c9 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "signale": "^1.4.0", "sinon": "^15.2.0", "tinyify": "3.0.0", + "tsd": "^0.31.0", "xo": "^0.56.0" }, "engines": { @@ -120,7 +121,7 @@ "build": "npm run build:clean && npm run build:lib && npm run build:dist", "build:clean": "rimraf lib dist", "build:dist": "npm run browserify && npm run minify", - "build:lib": "babel --config-file ./.lib.babelrc.json src --out-dir lib", + "build:lib": "babel --config-file ./.lib.babelrc.json src --out-dir lib --copy-files", "lint": "xo --fix && remark . -qfo && fixpack", "lint-build": "npm run lint-lib && npm run lint-dist", "lint-dist": "eslint --no-inline-config -c .dist.eslintrc.json dist", @@ -129,7 +130,8 @@ "nyc": "cross-env NODE_ENV=test nyc ava", "prepare": "husky install", "pretest": "npm run lint", - "test": "npm run build && npm run lint-build && npm run nyc" + "test": "npm run build && npm run lint-build && tsd && npm run nyc" }, + "types": "lib/index.d.ts", "unpkg": "dist/axe.min.js" } diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..f70bbd9 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,247 @@ +export default Axe; + +declare const Axe: Axe.Constructor; + +declare namespace Axe { + // These should match the defaults in the index file + namespace Defaults { + type OmittedLoggerKeys = 'config' | 'log'; + type Levels = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + } + + export type Constructor = new ( + config?: Options + ) => Axe; + + export type Axe = ParentLogger & + Prototype & + LoggerMethods & + LoggerMethodAliases; + + // This is the inherited methods from the logger object + export type ParentLogger = Omit< + { + [K in Exclude]: T[K]; + }, + Defaults.Levels + >; + + export type LoggerMethod = (...args: any[]) => Promise; + + export type LoggerMethods = { + [K in Defaults.Levels]: LoggerMethod; + }; + + export type LoggerMethodAliases = { + err: LoggerMethods['error']; + warning: LoggerMethods['warn']; + }; + + export type Prototype = { + log: (...args: any[]) => Promise; + + setLevel(leveL: string): void; + getNormalizedLevel(level: string): string; + setName(name: string): void; + pre(level: string, fn: PreHook): void; + post(level: string, fn: PostHook): void; + }; + + type Logger< + ObjectType = { + info?: (...args: any[]) => any; + log?: (...args: any[]) => any; + }, + KeysType extends keyof ObjectType = keyof ObjectType + > = + // Require at least one of the keys + { + [Key in KeysType]-?: Required> & + Partial>>; + }[KeysType] & + Record; + + /** + * A pre-hook is a function that runs before the logger method is invoked. + * It receives the method name, error, message, and metadata as arguments. + * It should return an array of the error, message, and metadata. + * + * @param method - The method name that will be invoked (e.g. 'info', 'warn', 'error', 'fatal') + * @param err - The error object (if any) + * @param message - The message to log (if any) + * @param meta - The metadata object to log (if any) + * + * @returns An array of the error, message, and metadata + */ + export type PreHook = ( + method: string, + err: any, + message: any, + meta: any + ) => [any, any, any]; + + /** + * A post-hook is a function that runs after the logger method is invoked. + * + * @param method - The method name that was invoked (e.g. 'info', 'warn', 'error', 'fatal') + * @param err - The error object (if any) + * @param message - The message that was logged (if any) + * @param meta - The metadata object that was logged (if any) + */ + export type PostHook = ( + method: string, + err: any, + message: any, + meta: any + ) => PromiseLike | any; + + export type Options = { + /** + * Attempts to parse a boolean value from `process.env.AXE_SHOW_STACK`). + * **If this value is `true`, then if `message` is an instance of an Error, + * it will be invoked as the first argument to logger methods. + * If this is `false`, then only the `err.message` will be invoked as the first argument to logger methods.** + * + * Basically if `true` it will call `logger.method(err)` and if `false` it will call `logger.method(err.message)`. + * If you pass `err` as the first argument to a logger method, + * then it will show the stack trace via `err.stack` typically. + * + * @default true + */ + showStack?: boolean; + + meta?: { + /** + * Attempts to parse a boolean value from `process.env.AXE_SHOW_META` + * – meaning you can pass a flag `AXE_SHOW_META=true node app.js` when needed for debugging), + * whether or not to output metadata to logger methods. + * If set to `false`, then fields will not be omitted nor picked; + * the entire meta object will be hidden from logger output. + * + * @default true + */ + show?: boolean; + + /** + * Attempts to parse an Object mapping from `process.env.AXE_REMAPPED_META_FIELDS` + * (`,` and `:` delimited, e.g. `REMAPPED_META_FIELDS=foo:bar,beep.boop:beepBoop` to remap `meta.foo` to `meta.bar` and `meta.beep.boop` to `meta.beepBoop`). + * Note that this will clean up empty objects by default unless you set the option `meta.cleanupRemapping` to `false`). + * Supports dot-notation. + * + * @default {} + */ + remappedFields?: RemappedFields; + + /** + * Attempts to parse an array value from `process.env.AXE_OMIT_META_FIELDS` + * (`,` delimited) - meaning you can pass a flag `AXE_OMIT_META_FIELDS=user,id node app.js`), + * determining which fields to omit in the metadata passed to logger methods. + * Supports dot-notation. + * + * @default [] + */ + omittedFields?: string[]; + + /** + * Attempts to parse an array value from `process.env.AXE_PICK_META_FIELDS` + * (`,` delimited) - meaning you can pass a flag, e.g. `AXE_PICK_META_FIELDS=request.headers,response.headers node app.js` + * which would pick from `meta.request` and `meta.response` *only* `meta.request.headers` and `meta.response.headers`), + * **This takes precedence after fields are omitted, which means this acts as a whitelist.** + * Supports dot-notation. + * **As of v11.2.0 this now supports Symbols, but only top-level symbols via `Reflect.ownKeys` (not recursive yet).** + * + * @default [] + */ + pickedFields?: Array; + + /** + * Whether or not to cleanup empty objects after remapping operations are completed) + * + * @default true + */ + cleanupRemapping?: boolean; + + /** + * Whether to suppress HTTP metadata (prevents logger invocation with second arg `meta`) + * if `meta.is_http` is `true` (via [parse-request][] v5.1.0+). + * If you manually set `meta.is_http = true` and this is `true`, then `meta` arg will be suppressed as well. + * + * @default true + */ + hideHTTP?: boolean; + + /** + * If this value is provided as a String, then if `meta[config.hideMeta]` is `true`, + * it will suppress the entire metadata object `meta` (the second arg) from being passed/invoked to the logger. + * This is useful when you want to suppress metadata from the logger invocation, + * but still persist it to post hooks (e.g. for sending upstream to your log storage provider). + * This helps to keep development and production console output clean while also allowing you to still store the meta object. + * + * @deafult 'hide_meta' + */ + hideMeta?: string | boolean; + }; + + /** + * Whether or not to invoke logger methods. Pre and post hooks will still run even if this option is set to `false`. + * + * @default false + */ + silent?: boolean; + + /** + * Defaults to `console` with {@link https://github.com/paulmillr/console-polyfill console-polyfill} added automatically, though **you can bring your own logger**. + * See {@link https://github.com/cabinjs/axe?tab=readme-ov-file#custom-logger custom-logger} – you can pass an instance of `pino`, `signale`, `winston`, `bunyan`, etc. + * + * @default console + */ + logger?: TLogger; + + /** + * The default name for the logger (defaults to `false` in development environments, + * which does not set `logger.name`) + * – this is useful if you are using a logger like `pino` which prefixes log output with the name set here. + * + * @default `false` if `NODE_ENV` is `"development"` otherwise the value of `process.env.HOSTNAME` or `os.hostname()` + */ + name?: string | boolean; + + /** + * The default level of logging to invoke `logger` methods for (defaults to `info`, + * which includes all logs including info and higher in severity (e.g. `info`, `warn`, `error`, `fatal`) + * + * @default 'info' + */ + level?: string; + + /** + * An Array of logging levels to support. + * You usually shouldn't change this unless you want to prevent logger methods from being invoked or prevent hooks from being run for a certain log level. + * If an invalid log level is attempted to be invoked, and if it is not in this Array, then no hooks and no logger methods will be invoked. + * + * @default ['info','warn','error','fatal'] + */ + levels?: string[]; + + /** + * Attempts to parse a boolean value from `process.env.AXE_APP_INFO`) - whether or not to parse application information (using [parse-app-info][]). + * + * @default true + */ + appInfo?: boolean; + + /** + * See {@link https://github.com/cabinjs/axe?tab=readme-ov-file#hooks Hooks} + * + * @defualt { pre: [], post: [] } + */ + hooks?: { + pre?: PreHook[]; + post?: PostHook[]; + }; + }; + + type RemappedFields = { + [key: string]: string | RemappedFields; + }; +} diff --git a/src/index.js b/src/index.js index 3e91fd6..95298a0 100644 --- a/src/index.js +++ b/src/index.js @@ -99,6 +99,10 @@ function isFunction(value) { return typeof value === 'function'; } +function getFunction(value) { + return isFunction(value) ? value : null; +} + class Axe { // eslint-disable-next-line complexity constructor(config = {}) { @@ -182,16 +186,21 @@ class Axe { // Bind helper functions for each log level for (const element of levels) { // Ensure function exists in logger passed - if (typeof this.config.logger[element] !== 'function') { - if (element === 'fatal') { - this.config.logger.fatal = - this.config.logger.error || - this.config.logger.info || - this.config.logger.log; - } else { - this.config.logger[element] = - this.config.logger.info || this.config.logger.log; - } + if (element === 'fatal') { + this.config.logger.fatal = + getFunction(this.config.logger[element]) || + getFunction(this.config.logger.error) || + getFunction(this.config.logger.info) || + getFunction(this.config.logger.log); + } else { + this.config.logger[element] = + getFunction(this.config.logger[element]) || + getFunction(this.config.logger.info) || + getFunction(this.config.logger.log); + } + + if (!isFunction(this.config.logger[element])) { + throw new Error(`\`${element}\` must be a function on the logger.`); } // Bind log handler which normalizes args and populates meta diff --git a/test-d/index.test-d.ts b/test-d/index.test-d.ts new file mode 100644 index 0000000..cce4ecb --- /dev/null +++ b/test-d/index.test-d.ts @@ -0,0 +1,13 @@ +import { expectType, expectNotType } from 'tsd'; +import Axe from '../lib'; + +const logger = new Axe({}); + +expectType(Axe); +expectType(logger); + +// We can expect that the logger object has all the methods of the Console object except 'config' and 'log' and expected levels +expectType(logger.assert); +expectType(logger.count); +expectType(logger.table); +expectNotType(logger.log); diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..85ca672 --- /dev/null +++ b/test/index.js @@ -0,0 +1,22 @@ +const test = require('ava'); +const Axe = require('../lib'); + +const loggerFnsCheck = test.macro({ + exec(t, field) { + t.throws( + () => + new Axe({ + logger: { + [field]: field + } + }) + ); + }, + title(_providedTitle = '', field) { + return `throws if logger fn "${field}" is not a function but is defined`; + } +}); + +for (const field of ['error', 'info', 'log']) { + test(loggerFnsCheck, field); +}