commit ea8fd991a09202199bf4d7c66cadc9c626f8efd0 Author: Alexander Zinn Date: Sat Aug 16 15:12:58 2025 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33e67bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store + +.vscode +node_modules/ +dist/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6e1b559 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +save-exact=true +package-lock=false +@techniker-me:registry=https://registry-node.techniker.me +//registry-node.techniker.me/:_authToken="${REGISTRY_AUTH_TOKEN}" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8fdd954 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..caa814d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "arrowParens": "avoid", + "bracketSameLine": true, + "bracketSpacing": false, + "printWidth": 160, + "semi": true, + "singleAttributePerLine": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..751cb55 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# @techniker-me/logger + +Logger package diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..62bd43b --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,8 @@ +[install] +exact = true + +[install.lockfile] +save = false + +[install.scopes] +"@techniker-me" = {url = "https://registry-node.techniker.me", token = "${REGISTRY_AUTH_TOKEN}"} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d76408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,11 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + {files: ['**/*.{js,mjs,cjs,ts}']}, + {languageOptions: {globals: {...globals.browser, ...globals.node}}}, + pluginJs.configs.recommended, + ...tseslint.configs.recommended +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..256420d --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "@techniker-me/logger", + "version": "0.0.15", + "description": "A logger package for logging", + "type": "module", + "types": "./dist/types/index.d.ts", + "exports": { + "types": "./dist/types/index.d.ts", + "import": "./dist/browser/index.js", + "require": "./dist/node/index.js", + "default": "./dist/node/index.js" + }, + "private": false, + "author": { + "name": "Alexander Zinn", + "git+url": "https://git.techniker.me/techniker-me/tools.git" + }, + "publishConfig": { + "registry": "https://registry-node.techniker.me" + }, + "scripts": { + "ci-install": "bun install", + "ci-test": "bun test", + "ci-build": "bash scripts/ci-build.sh", + "ci-deploy:ga": "bash scripts/ci-deploy.sh --beta false", + "ci-deploy:beta": "bash scripts/ci-deploy.sh --beta true", + "format": "prettier ./ --write", + "prelint": "bun run format", + "lint": "eslint \"./src\"", + "lint:fix": "eslint \"./src\" --fix", + "test": "bun test", + "clean": "rm -rf dist", + "prebuild": "bun run clean", + "build": "bash scripts/ci-build.sh", + "build:node:debug": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/node", + "build:browser:debug": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/browser", + "build:node": "bun build ./src/index.ts --target=node --sourcemap=none --format=cjs --splitting --minify --outdir=dist/node", + "build:browser": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --splitting --minify --outdir=dist/browser", + "build:types": "tsc -p tsconfig.json", + "build:prepare-package-json": "bash scripts/prepare-package-json.sh", + "dev": "bun run build:types && bun --watch ./src/index.ts", + "versionbump:minor": "bash scripts/version-bump.sh minor", + "versionbump:major": "bash scripts/version-bump.sh major", + "versionbump:patch": "bash scripts/version-bump.sh patch" + }, + "devDependencies": { + "@eslint/js": "9.21.0", + "@types/node": "22.14.1", + "bun-types": "^1.2.4", + "eslint": "9.21.0", + "globals": "16.0.0", + "prettier": "3.5.3", + "tsx": "4.19.3", + "typescript": "5.8.2", + "typescript-eslint": "8.26.0" + }, + "dependencies": { + "@techniker-me/tools": "2025.0.16" + }, + "files": [ + "package.json", + "dist", + "README.md" + ] +} diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh new file mode 100644 index 0000000..6cb0a60 --- /dev/null +++ b/scripts/ci-build.sh @@ -0,0 +1,29 @@ +#! /usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +distDirectory=${DIST_DIRECTORY:-"dist"} + +rm -rf ${distDirectory} + +bun run lint +bun run build:node +bun run build:browser +bun run build:types +bun run build:prepare-package-json + +echo "Copying [.npmrc] to [${distDirectory}]" +cp .npmrc ./${distDirectory} + +echo "Copying [.nvmrc] to [${distDirectory}]" +cp .nvmrc ./${distDirectory} + +echo "Copying [README.md] to [${distDirectory}]" +cp README.md ./${distDirectory} + +ls ${distDirectory} + +echo -e "\nci-build complete!" +exit 0 diff --git a/scripts/ci-deploy.h b/scripts/ci-deploy.h new file mode 100644 index 0000000..e69de29 diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh new file mode 100755 index 0000000..7e77b0e --- /dev/null +++ b/scripts/ci-deploy.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +registryUrl="https://registry-node.techniker.me" +packageVersionToDeploy="" +isBeta="true" + +while [[ "${#}" -gt 0 ]]; do + case "${1}" in + --beta) + isBeta="${2}" + shift 2 + ;; + --version) + packageVersionToDeploy="${2}" + shift 2 + ;; + *) + echo "Unknown option [${1}]" + exit "${LINENO}" + ;; + esac +done + +function cleanDirectory { + local directory="${1}" + + if [ -d "${directory}" ]; then + echo "Deleting [${directory}]..." + + rm -rf "${directory}" + fi +} + +function removePackageJsonMember { + local packageJsonPath="dist/package.json" + local memberToRemove="${1}" + + if [ -f "${packageJsonPath}" ]; then + echo "Removing [${memberToRemove}] from the dist/package.json" + + jq "del(.${memberToRemove})" "${packageJsonPath}" > tmp.$$.json && mv tmp.$$.json "$packageJsonPath" + else + echo "Error: [${packageJsonPath}] not found." + fi +} + +function updatePackageJsonVersion { + local versionToUpdate="${1}" + + if [ isBeta == "true" ]; then + echo "Version to update [${versionToUpdate}] Contains beta" + echo "Updating package.json version to [${versionToUpdate}]" + + local packageJsonVersion=$(jq -r '.version' package.json) + + sed -i "s/\"version\": \"${packageJsonVersion}\"/\"version\": \"${versionToUpdate}\"/" dist/package.json + fi +} + + +echo "Deploying [${packageVersionToDeploy}]"; +echo "isBeta [${isBeta}]" + +cleanDirectory "dist" + +npm run ci-build + +removePackageJsonMember "devDependencies" +removePackageJsonMember "scripts" + + +if [ "${isBeta}" == "true" ]; then + updatePackageJsonVersion "${packageVersionToDeploy}" + npm publish --registry "${registryUrl}" --tag beta +else + npm publish --registry "${registryUrl}" +fi \ No newline at end of file diff --git a/scripts/prepare-package-json.sh b/scripts/prepare-package-json.sh new file mode 100644 index 0000000..a516f9a --- /dev/null +++ b/scripts/prepare-package-json.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +distDirectory=${DIST_DIRECTORY:-"dist"} +packageJsonPath=${PACKAGE_JSON_PATH:-"package.json"} + +if [ ! -d "${distDirectory}" ]; then + echo "Unable to prepare package.json, [${distDirectory}] not found" + exit $LINENO +fi + +echo "Preparing [package.json] to [${distDirectory}]" +jq '{name, version, author, type, types, exports, files, publishConfig}' "${packageJsonPath}" > "${distDirectory}/package.json" diff --git a/scripts/version-bump.sh b/scripts/version-bump.sh new file mode 100755 index 0000000..f4252c6 --- /dev/null +++ b/scripts/version-bump.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Check if a parameter was provided +if [ -z "$1" ]; then + echo "Usage: ./version-bump.sh [major|minor|patch]" + exit 1 +fi + +# Read current version +current_version=$(node -p "require('./package.json').version") +IFS='.' read -r major minor patch <<< "$current_version" + +# Update version based on parameter +case "$1" in + "major") + new_version="$((major + 1)).0.0" + ;; + "minor") + new_version="$major.$((minor + 1)).0" + ;; + "patch") + new_version="$major.$minor.$((patch + 1))" + ;; + *) + echo "Invalid parameter. Use: major, minor, or patch" + exit 1 + ;; +esac + +# Update package.json +node -e " +const pkg = require('./package.json'); +pkg.version = '$new_version'; +require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); +" + +echo "Version bumped from $current_version to $new_version" \ No newline at end of file diff --git a/src/Defaults.ts b/src/Defaults.ts new file mode 100644 index 0000000..6f210bf --- /dev/null +++ b/src/Defaults.ts @@ -0,0 +1,11 @@ +import {LoggingLevel} from './level/LoggingLevel'; + +export default class Defaults { + static get loggingLevel(): LoggingLevel { + return LoggingLevel.Info; + } + + private constructor() { + throw new Error('Defaults is a static class that may not be instantiated'); + } +} diff --git a/src/ILogger.ts b/src/ILogger.ts new file mode 100644 index 0000000..130839b --- /dev/null +++ b/src/ILogger.ts @@ -0,0 +1,8 @@ +export default interface ILogger { + info(message: string, ...optionalParameters: unknown[]): void; + warn(message: string, ...optionalParameters: unknown[]): void; + error(message: string, ...optionalParameters: unknown[]): void; + debug(message: string, ...optionalParameters: unknown[]): void; + trace(message: string, ...optionalParameters: unknown[]): void; + silly(message: string, ...optionalParameters: unknown[]): void; +} diff --git a/src/Logger.ts b/src/Logger.ts new file mode 100644 index 0000000..c08d17c --- /dev/null +++ b/src/Logger.ts @@ -0,0 +1,102 @@ +import IAppender from './appenders/IAppender'; +import {LoggingLevel} from './level/LoggingLevel'; +import LoggingLevelMapping from './level/LoggingLevelMapping'; +import Threshold from './level/Threshold'; + +export default class Logger { + private readonly _category: string; + private readonly _threshold: Threshold; + private readonly _appenders: Set; + + constructor(category: string, threshold: Threshold, appenders: Set) { + this._category = category; + this._threshold = threshold; + this._appenders = appenders; + } + + public info(message: string, ...optionalParameters: unknown[]): void { + if (this._threshold.value > LoggingLevel.Info) { + return; + } + + this.log(LoggingLevel.Info, message, ...optionalParameters); + } + + public warn(message: string, ...optionalParameters: unknown[]): void { + if (this._threshold.value > LoggingLevel.Warn) { + return; + } + + this.log(LoggingLevel.Warn, message, ...optionalParameters); + } + + public error(message: string, ...optionalParameters: unknown[]): void { + if (this._threshold.value > LoggingLevel.Error) { + return; + } + + this.log(LoggingLevel.Error, message, ...optionalParameters); + } + + public debug(message: string, ...optionalParameters: unknown[]): void { + if (this._threshold.value > LoggingLevel.Debug) { + return; + } + + this.log(LoggingLevel.Debug, message, ...optionalParameters); + } + + public trace(message: string, ...optionalParameters: unknown[]): void { + if (this._threshold.value > LoggingLevel.Trace) { + return; + } + + this.log(LoggingLevel.Trace, message, ...optionalParameters); + } + + public silly(message: string, ...optionalParameters: unknown[]): void { + if (this._threshold.value > LoggingLevel.Silly) { + return; + } + + this.log(LoggingLevel.Silly, message, ...optionalParameters); + } + + private formatMessage(message: string, ...optionalParameters: unknown[]): string { + let optionalParameterIndex = 0; + + return message.replace(/%[sdj]/g, match => { + if (optionalParameterIndex >= optionalParameters.length) { + return match; + } + + const param = optionalParameters[optionalParameterIndex++]; + + switch (match) { + case '%s': + return String(param); + case '%d': + return typeof param === 'number' ? param.toString() : 'NaN'; + case '%j': + try { + return JSON.stringify(param); + } catch { + return '[Circular]'; + } + + default: + return match; + } + }); + } + + private log(loggingLevel: LoggingLevel, message: string, ...optionalParameters: unknown[]): void { + const timestamp = new Date().toISOString(); + const formattedMessage = this.formatMessage(message, ...optionalParameters); + const level = LoggingLevelMapping.convertLoggingLevelToLoggingLevelType(loggingLevel); + + for (const appender of this._appenders) { + appender.log(timestamp, level, this._category, formattedMessage); + } + } +} diff --git a/src/LoggerFactory.ts b/src/LoggerFactory.ts new file mode 100644 index 0000000..a2d4b76 --- /dev/null +++ b/src/LoggerFactory.ts @@ -0,0 +1,49 @@ +import {Disposable} from '@techniker-me/tools'; +import ConsoleAppender from './appenders/ConsoleAppender'; +import IAppender from './appenders/IAppender'; +import Threshold from './level/Threshold'; +import Logger from './Logger'; +import {LoggingLevelType} from './level/LoggingLevel'; +import LoggingLevelMapping from './level/LoggingLevelMapping'; +import TechnikerMeAppender from './appenders/TechnikerMeAppender'; + +type Category = string; + +export default class LoggerFactory { + private static readonly _appenders: Set = new Set(); + private static readonly _threshold: Threshold = new Threshold(); + private static readonly _loggers: Map = new Map(); + + static { + this.applyConsoleAppender(); + this.applyRemoteAppender(); + } + + public static getLogger(category: string): Logger { + if (!LoggerFactory._loggers.has(category)) { + this._loggers.set(category, new Logger(category, LoggerFactory._threshold, LoggerFactory._appenders)); + } + + return LoggerFactory._loggers.get(category) as Logger; + } + + public static applyApppender(appender: IAppender): Disposable { + LoggerFactory._appenders.add(appender); + + return new Disposable(() => LoggerFactory._appenders.delete(appender)); + } + + public static setLoggingLevel(loggingLevelType: LoggingLevelType): void { + const loggingLevel = LoggingLevelMapping.convertLoggingLevelTypeToLoggingLevel(loggingLevelType); + + LoggerFactory._threshold.value = loggingLevel; + } + + private static applyConsoleAppender(): void { + LoggerFactory.applyApppender(new ConsoleAppender()); + } + + private static applyRemoteAppender(): void { + LoggerFactory.applyApppender(new TechnikerMeAppender()); + } +} diff --git a/src/appenders/ConsoleAppender.ts b/src/appenders/ConsoleAppender.ts new file mode 100644 index 0000000..8051ec4 --- /dev/null +++ b/src/appenders/ConsoleAppender.ts @@ -0,0 +1,31 @@ +import {LoggingLevel, LoggingLevelType} from '../level/LoggingLevel'; +import LoggingLevelMapping from '../level/LoggingLevelMapping'; +import IAppender from './IAppender'; +import {assertUnreachable} from '@techniker-me/tools'; + +export default class ConsoleAppender implements IAppender { + public log(timestamp: string, level: LoggingLevelType, category: string, message: string) { + const loggingLevel = LoggingLevelMapping.convertLoggingLevelTypeToLoggingLevel(level); + + switch (loggingLevel) { + case LoggingLevel.Off: + break; + + case LoggingLevel.Warn: + case LoggingLevel.Error: + console.error(`${timestamp} [${level}] [${category}] ${message}`); + break; + + case LoggingLevel.Info: + case LoggingLevel.Debug: + case LoggingLevel.Trace: + case LoggingLevel.Silly: + case LoggingLevel.All: + console.log(`${timestamp} [${level}] [${category}] ${message}`); + break; + + default: + assertUnreachable(loggingLevel); + } + } +} diff --git a/src/appenders/IAppender.ts b/src/appenders/IAppender.ts new file mode 100644 index 0000000..5a44771 --- /dev/null +++ b/src/appenders/IAppender.ts @@ -0,0 +1,5 @@ +import {LoggingLevelType} from '../level/LoggingLevel'; + +export default interface IAppender { + log(timestamp: string, level: LoggingLevelType, category: string, message: string): void; +} diff --git a/src/appenders/TechnikerMeAppender.ts b/src/appenders/TechnikerMeAppender.ts new file mode 100644 index 0000000..a18e57c --- /dev/null +++ b/src/appenders/TechnikerMeAppender.ts @@ -0,0 +1,60 @@ +import type {LoggingLevelType} from '../level/LoggingLevel'; +import type IAppender from './IAppender'; + +type LogMessage = { + timestamp: string; + level: string; + category: string; + message: string; +}; + +export default class TechnikerMeAppender implements IAppender { + private readonly _logRecorderUrl: string = 'https://logserver.techniker.me/api/logs'; + private readonly _domain: string = typeof window !== 'undefined' ? (window.location?.hostname ?? '') : ''; + private readonly _logMessageQueue: LogMessage[] = []; + private _pendingPostLogMessagePromise: Promise | undefined = undefined; + + public log(timestamp: string, level: LoggingLevelType, category: string, message: string): void { + const logMessage = { + timestamp, + domain: this._domain, + level, + category, + message + }; + this.queueMessage(logMessage); + this.postLogMessage(); + } + + private async postLogMessage(): Promise { + const logMessage = this._logMessageQueue.shift(); + + if (!logMessage || this._pendingPostLogMessagePromise !== undefined) { + return; + } + + try { + if (typeof fetch === 'undefined') { + console.error('Fetch API is not available in this environment'); + return; + } + + this._pendingPostLogMessagePromise = fetch(this._logRecorderUrl, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + mode: 'no-cors', + method: 'POST', + body: JSON.stringify(logMessage) + }).then(() => (this._pendingPostLogMessagePromise = undefined)); + } catch (e) { + console.error('Unable to send logs due to [%o]', e); + return; + } + } + + private queueMessage(logMessage: LogMessage): void { + this._logMessageQueue.push(logMessage); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a954adc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +import type ILogger from './ILogger'; +import type IAppender from './appenders/IAppender'; +import LoggerFactory from './LoggerFactory'; +import LoggingLevelMapping from './level/LoggingLevelMapping'; + +export type {ILogger, IAppender}; +export {LoggerFactory, LoggingLevelMapping}; +export default {LoggerFactory, LoggingLevelMapping}; diff --git a/src/level/LoggingLevel.ts b/src/level/LoggingLevel.ts new file mode 100644 index 0000000..da47397 --- /dev/null +++ b/src/level/LoggingLevel.ts @@ -0,0 +1,12 @@ +export enum LoggingLevel { + Off = -1, + Info = 10, + Warn = 20, + Error = 30, + Debug = 40, + Trace = 50, + Silly = 60, + All = 100 +} + +export type LoggingLevelType = 'Off' | 'Info' | 'Warn' | 'Error' | 'Debug' | 'Trace' | 'Silly' | 'All'; diff --git a/src/level/LoggingLevelMapping.ts b/src/level/LoggingLevelMapping.ts new file mode 100644 index 0000000..05dab62 --- /dev/null +++ b/src/level/LoggingLevelMapping.ts @@ -0,0 +1,51 @@ +import {assertUnreachable} from '@techniker-me/tools'; +import {LoggingLevel, LoggingLevelType} from './LoggingLevel'; + +export default class LoggingLevelMapping { + public static convertLoggingLevelToLoggingLevelType(loggingLevel: LoggingLevel): LoggingLevelType { + switch (loggingLevel) { + case LoggingLevel.Off: + return 'Off'; + case LoggingLevel.Info: + return 'Info'; + case LoggingLevel.Warn: + return 'Warn'; + case LoggingLevel.Error: + return 'Error'; + case LoggingLevel.Debug: + return 'Debug'; + case LoggingLevel.Trace: + return 'Trace'; + case LoggingLevel.Silly: + return 'Silly'; + case LoggingLevel.All: + return 'All'; + + default: + assertUnreachable(loggingLevel); + } + } + + public static convertLoggingLevelTypeToLoggingLevel(loggingLevelType: LoggingLevelType): LoggingLevel { + switch (loggingLevelType) { + case 'Off': + return LoggingLevel.Off; + case 'Info': + return LoggingLevel.Info; + case 'Warn': + return LoggingLevel.Warn; + case 'Error': + return LoggingLevel.Error; + case 'Debug': + return LoggingLevel.Debug; + case 'Trace': + return LoggingLevel.Trace; + case 'Silly': + return LoggingLevel.Silly; + case 'All': + return LoggingLevel.All; + default: + assertUnreachable(loggingLevelType); + } + } +} diff --git a/src/level/Threshold.ts b/src/level/Threshold.ts new file mode 100644 index 0000000..f59be9e --- /dev/null +++ b/src/level/Threshold.ts @@ -0,0 +1,21 @@ +import {Subject} from '@techniker-me/tools'; +import Defaults from '../Defaults'; +import {LoggingLevel} from './LoggingLevel'; + +class Threshold { + private _threshold: Subject = new Subject(LoggingLevel.Debug); + + constructor(loggingLevel?: LoggingLevel) { + this._threshold = new Subject(loggingLevel ?? Defaults.loggingLevel); + } + + set value(value: LoggingLevel) { + this._threshold.value = value; + } + + get value(): LoggingLevel { + return this._threshold.value; + } +} + +export default Threshold; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5430fe3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "outDir": "dist/types", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "emitDeclarationOnly": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2020", + "lib": ["DOM", "ES2020"], + "allowJs": false, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "composite": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "tests"] +}