Compare commits

...

7 Commits

Author SHA1 Message Date
f6d0afb98a Update bun.lock and remove DependencyManager tests
- Added new dependency "@techniker-me/tools" and updated existing dependencies to specific versions in bun.lock
- Removed the DependencyManager test file as part of codebase cleanup
2025-10-30 03:16:04 -04:00
d332e08665 Add HTTP routing and server management classes
- Introduced UserApiRoute for managing user-related API routes
- Implemented HttpRouteManager for registering and retrieving HTTP routes
- Created HttpServer class to initialize and start an Express server with route management
- Added middleware and static route handling in HttpServer
2025-10-30 03:15:50 -04:00
64407db695 Add dependency management classes and interfaces
- Introduced Type and NamedType classes for type management
- Implemented IDependencyProvider interface for dependency provision
- Updated DependencyManager with a new lifecycle type 'service'
- Added methods for type comparison and URN generation in Type and NamedType
2025-10-30 03:15:41 -04:00
1587ed7428 Add logging framework with Logger, LoggerFactory, and appenders
- Introduced ILogger interface for logging methods
- Implemented Logger class with various logging levels and message formatting
- Created LoggerFactory for managing logger instances and appender configuration
- Added LoggingLevel enum and mapping for logging level types
- Developed ConsoleAppender and TechnikerMeAppender for different logging outputs
- Implemented appender management with AppenderFactory and base Appender class
- Established Threshold class for controlling logging levels
2025-10-30 03:15:16 -04:00
cb34256276 Add observable pattern implementation with Subject and ReadOnlySubject classes
- Introduced Subject class for managing state and notifying listeners
- Added ReadOnlySubject class to provide read-only access to Subject values
- Created assertUnreachable utility function for unreachable code handling
- Updated observables index file to export Subject and ReadOnlySubject
2025-10-30 03:14:53 -04:00
3fe157541c Add .npmrc configuration and update package.json
- Created a new .npmrc file with custom settings for package-lock and registry
- Updated package.json to include new devDependencies for @types/express and @types/morgan
- Restored private status and scripts section in package.json, ensuring proper project setup
2025-10-30 03:13:51 -04:00
e18e3523e5 Add disposable management classes and interface
- Introduced IDisposable interface for defining disposable behavior
- Implemented Disposable class for single disposable resources
- Created DisposableList class to manage multiple disposables
- Updated index file to export the new classes and interface
2025-10-30 03:13:35 -04:00
36 changed files with 900 additions and 380 deletions

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
package-lock=false
save-exact=true
@techniker-me:registry=https://npm.techniker.me

View File

@@ -1,365 +0,0 @@
import {describe, test, expect, beforeEach} from 'bun:test';
import {DependencyManager} from '../../src/di/DependencyManager';
// Test classes for dependency injection
class Logger {
log(message: string) {
return `LOG: ${message}`;
}
}
class Database {
constructor(private logger: Logger) {}
query(sql: string) {
this.logger.log(`Executing: ${sql}`);
return 'result';
}
getLogger() {
return this.logger;
}
}
class UserService {
constructor(
private db: Database,
private logger: Logger
) {}
getUser(id: number) {
this.logger.log(`Getting user ${id}`);
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
getDb() {
return this.db;
}
getLogger() {
return this.logger;
}
}
class CircularA {
constructor(public b: CircularB) {}
}
class CircularB {
constructor(public a: CircularA) {}
}
describe('DependencyManager', () => {
let dm: DependencyManager;
beforeEach(() => {
dm = new DependencyManager();
});
describe('register', () => {
test('should register a class without dependencies', () => {
expect(() => {
dm.register('Logger', Logger, []);
}).not.toThrow();
});
test('should register a class with dependencies', () => {
dm.register('Logger', Logger, []);
expect(() => {
dm.register('Database', Database, ['Logger']);
}).not.toThrow();
});
test('should throw error when registering duplicate name', () => {
dm.register('Logger', Logger, []);
expect(() => {
dm.register('Logger', Logger, []);
}).toThrow('Error: [Logger] is already registered');
});
test('should throw error for invalid lifecycle', () => {
expect(() => {
dm.register('Logger', Logger, [], 'invalid' as any);
}).toThrow('Invalid lifecycle [invalid]');
});
test('should accept valid lifecycle types', () => {
expect(() => {
dm.register('Logger1', Logger, [], 'singleton');
}).not.toThrow();
expect(() => {
dm.register('Logger2', Logger, [], 'instance');
}).not.toThrow();
expect(() => {
dm.register('Logger3', Logger, [], 'service');
}).not.toThrow();
});
});
describe('resolve', () => {
test('should resolve a simple class without dependencies', () => {
dm.register('Logger', Logger, []);
const logger = dm.resolve<Logger>('Logger');
expect(logger).toBeInstanceOf(Logger);
expect(logger.log('test')).toBe('LOG: test');
});
test('should throw error when resolving unregistered dependency', () => {
expect(() => {
dm.resolve('NonExistent');
}).toThrow('Error: Unable to resolve unregistered [NonExistent]');
});
test('should resolve class with dependencies', () => {
dm.register('Logger', Logger, []);
dm.register('Database', Database, ['Logger']);
const db = dm.resolve<Database>('Database');
expect(db).toBeInstanceOf(Database);
expect(db.getLogger()).toBeInstanceOf(Logger);
});
test('should resolve nested dependencies', () => {
dm.register('Logger', Logger, []);
dm.register('Database', Database, ['Logger']);
dm.register('UserService', UserService, ['Database', 'Logger']);
const userService = dm.resolve<UserService>('UserService');
expect(userService).toBeInstanceOf(UserService);
expect(userService.getDb()).toBeInstanceOf(Database);
expect(userService.getLogger()).toBeInstanceOf(Logger);
});
});
describe('singleton lifecycle', () => {
test('should return same instance for singleton lifecycle', () => {
dm.register('Logger', Logger, [], 'singleton');
const logger1 = dm.resolve<Logger>('Logger');
const logger2 = dm.resolve<Logger>('Logger');
expect(logger1).toBe(logger2); // Same instance
});
test('should inject same singleton instance into multiple dependents', () => {
dm.register('Logger', Logger, [], 'singleton');
dm.register('Database', Database, ['Logger']);
dm.register('UserService', UserService, ['Database', 'Logger']);
const userService = dm.resolve<UserService>('UserService');
const db = userService.getDb();
const loggerFromUserService = userService.getLogger();
const loggerFromDatabase = db.getLogger();
expect(loggerFromUserService).toBe(loggerFromDatabase); // Same singleton instance
});
test('should use singleton by default', () => {
dm.register('Logger', Logger, []); // No lifecycle specified
const logger1 = dm.resolve<Logger>('Logger');
const logger2 = dm.resolve<Logger>('Logger');
expect(logger1).toBe(logger2);
});
});
describe('instance/service lifecycle (transient)', () => {
test('should return new instance each time for instance lifecycle', () => {
dm.register('Logger', Logger, [], 'instance');
const logger1 = dm.resolve<Logger>('Logger');
const logger2 = dm.resolve<Logger>('Logger');
expect(logger1).not.toBe(logger2); // Different instances
expect(logger1).toBeInstanceOf(Logger);
expect(logger2).toBeInstanceOf(Logger);
});
test('should return new instance each time for service lifecycle', () => {
dm.register('Logger', Logger, [], 'service');
const logger1 = dm.resolve<Logger>('Logger');
const logger2 = dm.resolve<Logger>('Logger');
expect(logger1).not.toBe(logger2); // Different instances
});
test('should inject new instances into each dependent', () => {
dm.register('Logger', Logger, [], 'instance');
dm.register('Database', Database, ['Logger']);
dm.register('UserService', UserService, ['Database', 'Logger']);
const userService = dm.resolve<UserService>('UserService');
const loggerFromUserService = userService.getLogger();
const loggerFromDatabase = userService.getDb().getLogger();
expect(loggerFromUserService).not.toBe(loggerFromDatabase); // Different instances
});
});
describe('circular dependency detection', () => {
test('should detect direct circular dependency', () => {
dm.register('CircularA', CircularA, ['CircularB']);
dm.register('CircularB', CircularB, ['CircularA']);
expect(() => {
dm.resolve('CircularA');
}).toThrow('Circular dependency detected');
});
test('should detect self-referencing circular dependency', () => {
dm.register('SelfRef', Logger, ['SelfRef']);
expect(() => {
dm.resolve('SelfRef');
}).toThrow('Circular dependency detected: [SelfRef]');
});
test('should detect indirect circular dependency chain', () => {
class A {
constructor(public b: any) {}
}
class B {
constructor(public c: any) {}
}
class C {
constructor(public a: any) {}
}
dm.register('A', A, ['B']);
dm.register('B', B, ['C']);
dm.register('C', C, ['A']);
expect(() => {
dm.resolve('A');
}).toThrow('Circular dependency detected');
});
});
describe('has', () => {
test('should return true for registered dependency', () => {
dm.register('Logger', Logger, []);
expect(dm.has('Logger')).toBe(true);
});
test('should return false for unregistered dependency', () => {
expect(dm.has('NonExistent')).toBe(false);
});
test('should not throw error for unregistered dependency', () => {
expect(() => {
dm.has('NonExistent');
}).not.toThrow();
});
});
describe('clearSingletons', () => {
test('should clear cached singleton instances', () => {
dm.register('Logger', Logger, [], 'singleton');
const logger1 = dm.resolve<Logger>('Logger');
dm.clearSingletons();
const logger2 = dm.resolve<Logger>('Logger');
expect(logger1).not.toBe(logger2); // Different instances after clearing
});
test('should not affect registrations', () => {
dm.register('Logger', Logger, []);
dm.clearSingletons();
expect(dm.has('Logger')).toBe(true);
expect(() => {
dm.resolve('Logger');
}).not.toThrow();
});
test('should work with multiple singletons', () => {
dm.register('Logger', Logger, [], 'singleton');
dm.register('Database', Database, ['Logger'], 'singleton');
const logger1 = dm.resolve<Logger>('Logger');
const db1 = dm.resolve<Database>('Database');
dm.clearSingletons();
const logger2 = dm.resolve<Logger>('Logger');
const db2 = dm.resolve<Database>('Database');
expect(logger1).not.toBe(logger2);
expect(db1).not.toBe(db2);
});
});
describe('complex scenarios', () => {
test('should handle mixed lifecycles', () => {
dm.register('Logger', Logger, [], 'instance'); // Transient
dm.register('Database', Database, ['Logger'], 'singleton');
dm.register('UserService', UserService, ['Database', 'Logger']);
const userService1 = dm.resolve<UserService>('UserService');
const userService2 = dm.resolve<UserService>('UserService');
// UserService is singleton by default
expect(userService1).toBe(userService2);
// Database should be same singleton
expect(userService1.getDb()).toBe(userService2.getDb());
});
test('should handle multiple dependencies of same type', () => {
class MultiDependent {
constructor(
public logger1: Logger,
public logger2: Logger
) {}
}
dm.register('Logger', Logger, [], 'instance');
dm.register('MultiDependent', MultiDependent, ['Logger', 'Logger']);
const multiDep = dm.resolve<MultiDependent>('MultiDependent');
// Should be different instances since Logger is transient
expect(multiDep.logger1).not.toBe(multiDep.logger2);
});
test('should handle no dependencies (empty array)', () => {
dm.register('Logger', Logger, []);
const logger = dm.resolve<Logger>('Logger');
expect(logger).toBeInstanceOf(Logger);
});
});
describe('type safety', () => {
test('should support generic type parameter', () => {
dm.register('Logger', Logger, []);
// TypeScript should infer the correct type
const logger = dm.resolve<Logger>('Logger');
// This should compile and work
expect(logger.log('test')).toBe('LOG: test');
});
test('should work without explicit type parameter', () => {
dm.register('Logger', Logger, []);
const logger = dm.resolve('Logger');
expect(logger).toBeInstanceOf(Logger);
});
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { DependencyManager } from '../../src/di/DependencyManager';
import { Logger, Database, UserService } from '../mocks';
describe('When Managing Dependencies', () => {
let dependencyManager: DependencyManager;
beforeEach(() => {
dependencyManager = new DependencyManager();
});
describe('GIVEN classes with no dependencies are registered with the dependency manager', () => {
beforeEach(() => {
dependencyManager.register('Logger', Logger, [], 'transient');
dependencyManager.register('Database', Database, [], 'transient');
});
it('resolves the class', () => {
const logger = dependencyManager.resolve<Logger>('Logger');
expect(logger).toBeDefined();
expect(logger).toBeInstanceOf(Logger);
});
describe('GIVEN the class is registered a second time', () => {
it('throws an error', () => {
expect(() => dependencyManager.register('Logger', Logger, [])).toThrow('Error: [Logger] is already registered');
});
});
});
describe('GIVEN a singleton class is registered with the dependency manager', () => {
beforeEach(() => {
dependencyManager.register('Logger', Logger, [], 'singleton');
});
it('resolves the class', () => {
const logger = dependencyManager.resolve<Logger>('Logger');
expect(logger).toBeDefined();
expect(logger).toBeInstanceOf(Logger);
});
describe('GIVEN a UserService with Logger dependency is registered', () => {
beforeEach(() => {
dependencyManager.register('UserService', UserService, ['Logger'], 'singleton');
});
it('resolves the same instance when requested multiple times', () => {
const userService = dependencyManager.resolve<UserService>('UserService');
const userService2 = dependencyManager.resolve<UserService>('UserService');
expect(userService).toBe(userService2);
expect(userService2).toBe(userService);
});
});
});
});

View File

@@ -0,0 +1,15 @@
import type {Logger} from './MockLogger';
export class Database {
private _logger: Logger;
constructor(logger: Logger) {
this._logger = logger;
}
public query(sql: string) {
this._logger.debug(`Executing: ${sql}`);
return 'result';
}
}

View File

@@ -0,0 +1,31 @@
export class Logger {
private _category: string;
constructor(category: string) {
this._category = category;
}
public info(message: string, ...optionalParams: any[]) {
console.info(this.formatMessage(message), ...optionalParams);
}
public debug(message: string, ...optionalParams: any[]) {
console.debug(this.formatMessage(message), ...optionalParams);
}
public error(message: string, ...optionalParams: any[]) {
console.error(this.formatMessage(message), ...optionalParams);
}
public warn(message: string, ...optionalParams: any[]) {
console.warn(this.formatMessage(message), ...optionalParams);
}
public trace(message: string, ...optionalParams: any[]) {
console.trace(this.formatMessage(message), ...optionalParams);
}
private formatMessage(message: string) {
return `${new Date().toISOString()} [${this._category}] ${message}`;
}
}

View File

@@ -0,0 +1,14 @@
import type {Logger} from './MockLogger';
import type {Database} from './MockDatabase';
export class UserService {
constructor(
private db: Database,
private logger: Logger
) {}
public getUser(id: number) {
this.logger.debug(`Getting user ${id}`);
return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}

3
__test__/mocks/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './MockDatabase';
export * from './MockLogger';
export * from './MockUserServices';

View File

@@ -1,8 +1,23 @@
{
"name": "myoopapp",
"module": "index.ts",
"type": "module",
"author": "Alexander Zinn",
"module": "index.ts",
"devDependencies": {
"@eslint/js": "9.38.0",
"@eslint/json": "0.13.2",
"@eslint/markdown": "7.5.0",
"@types/bun": "latest",
"@types/express": "5.0.5",
"@types/morgan": "1.9.10",
"eslint": "9.38.0",
"globals": "16.4.0",
"jiti": "2.6.1",
"prettier": "3.6.2",
"typescript-eslint": "8.46.2"
},
"peerDependencies": {
"typescript": "5.9.3"
},
"private": true,
"scripts": {
"format": "prettier --write .",
@@ -12,18 +27,8 @@
"test": "bun test",
"test:watch": "bun test --watch"
},
"devDependencies": {
"@eslint/js": "9.38.0",
"@eslint/json": "0.13.2",
"@eslint/markdown": "7.5.0",
"@types/bun": "latest",
"eslint": "9.38.0",
"globals": "16.4.0",
"jiti": "2.6.1",
"prettier": "3.6.2",
"typescript-eslint": "8.46.2"
},
"peerDependencies": {
"typescript": "5.9.3"
"type": "module",
"dependencies": {
"@techniker-me/tools": "2025.0.16"
}
}

11
src/Defaults.ts Normal file
View File

@@ -0,0 +1,11 @@
import { LoggingLevel } from './logger/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');
}
}

25
src/api/UserApiRoute.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { RouteHandler } from "../net/http/HttpRouteManger";
export class UserApiRoute {
private readonly _getRoutes: Record<string, RouteHandler> = {};
private readonly _postRoutes: Record<string, RouteHandler> = {};
private readonly _putRoutes: Record<string, RouteHandler> = {};
private readonly _deleteRoutes: Record<string, RouteHandler> = {};
public getGETRoutes(): Record<string, RouteHandler> {
return this._getRoutes;
}
public getPOSTRoutes(): Record<string, RouteHandler> {
return this._postRoutes;
}
public getPUTRoutes(): Record<string, RouteHandler> {
return this._putRoutes;
}
public getDELETERoutes(): Record<string, RouteHandler> {
return this._deleteRoutes;
}
}

View File

@@ -7,6 +7,7 @@ type Registration = {
/**
* Lifecycle types:
* - 'singleton': A single shared instance cached for the lifetime of the manager
* - 'sergice': A single shared instance cached for the lifetime of the manager
* - 'transient': A new instance created on each resolve (transient)
*/
type Lifecycle = 'transient' | 'singleton';

22
src/di/NamedType.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Type } from "./Type";
export class NamedType extends Type {
private readonly _name: string;
constructor(name: string, type: string) {
super(type);
this._name = name;
}
public getName(): string {
return this._name;
}
public equals(other: NamedType): boolean {
return super.equal(other) && other instanceof NamedType && this.getName() === other.getName();
}
public override toURN(): string {
return 'urn:namedtype:' + super.getType() + '#' + this._name;
}
}

19
src/di/Type.ts Normal file
View File

@@ -0,0 +1,19 @@
export class Type {
private readonly _type: string;
constructor(type: string) {
this._type = type;
}
public getType(): string {
return this._type;
}
public equal(other: Type): boolean {
return other instanceof Type && this.getType() === other.getType();
}
public toURN() {
return 'urn:type:' + this._type;
}
}

View File

@@ -0,0 +1,7 @@
import type { Type } from "../Type";
import type { NamedType } from "../NamedType";
export interface IDependencyProvider {
canProvide(type: Type | NamedType): boolean;
provide(type: Type | NamedType): unknown;
}

View File

@@ -0,0 +1,3 @@
export default function assertUnreachable(x: never): never {
throw new Error(`Unreachable code [${x}]`);
}

View File

@@ -0,0 +1,23 @@
import type IDisposable from './IDisposable';
export default class Disposable implements IDisposable {
private _isDisposed = false;
private _disposable: () => void;
constructor(disposable: () => void) {
this._disposable = disposable;
}
get isDisposed(): boolean {
return this._isDisposed;
}
public dispose(): void {
if (this._isDisposed) {
return;
}
this._disposable();
this._isDisposed = true;
}
}

View File

@@ -0,0 +1,17 @@
import type IDisposable from './IDisposable';
export default class DisposableList implements IDisposable {
private readonly _disposables: IDisposable[] = [];
public add(disposable: IDisposable): void {
this._disposables.push(disposable);
}
public dispose(): void {
while (this._disposables.length) {
const disposable = this._disposables.shift() as IDisposable;
disposable.dispose();
}
}
}

View File

@@ -0,0 +1,3 @@
export default interface IDisposable {
dispose(): void;
}

View File

@@ -0,0 +1,6 @@
import type IDisposable from './IDisposable';
import Disposable from './Disposable';
import DisposableList from './DisposableList';
export type {IDisposable};
export {Disposable, DisposableList};

View File

@@ -0,0 +1,18 @@
import Disposable from '../disposables/Disposable';
import Subject from './Subject';
export default class ReadOnlySubject<T> {
private readonly _subject: Subject<T>;
constructor(subject: Subject<T>) {
this._subject = subject;
}
get value(): T {
return this._subject.value;
}
public subscribe(listener: (value: T) => void): Disposable {
return this._subject.subscribe(listener);
}
}

View File

@@ -0,0 +1,39 @@
import Disposable from '../disposables/Disposable';
export default class Subject<T> {
private readonly _listeners: Array<(value: T) => void> = [];
private _value: T;
constructor(value: T) {
this._value = value;
}
set value(value: T) {
if (this._value === value) {
return;
}
this._value = value;
this.notifyListeners(value);
}
get value(): T {
return this._value;
}
public subscribe(listener: (value: T) => void): Disposable {
const listenerIndex = this._listeners.push(listener);
listener(this.value);
return new Disposable(() => {
this._listeners.splice(listenerIndex, 1);
});
}
private notifyListeners(value: T): void {
for (const listener of this._listeners) {
listener(value);
}
}
}

View File

@@ -0,0 +1,4 @@
import Subject from './Subject';
import ReadOnlySubject from './ReadOnlySubject';
export {Subject, ReadOnlySubject};

7
src/logger/ILogger.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface ILogger {
info(message: string, ...optionalParams: unknown[]): void;
debug(message: string, ...optionalParams: unknown[]): void;
warn(message: string, ...optionalParams: unknown[]): void;
error(message: string, ...optionalParams: unknown[]): void;
trace(message: string, ...optionalParams: unknown[]): void;
}

102
src/logger/Logger.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { ILogger } from './ILogger';
import { LoggingLevel } from './LoggingLevel';
import type { Threshold } from './Threshold';
import type { IAppender } from './appenders/IAppender';
import LoggingLevelMapping from './LoggingLevelMapping';
export default class Logger implements ILogger {
private readonly _category: string;
private readonly _threshold: Threshold;
private readonly _appenders: Set<IAppender>;
constructor(category: string, threshold: Threshold, appenders: Set<IAppender>) {
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);
}
}
}

View File

@@ -0,0 +1,43 @@
import type { IAppender } from "./appenders/IAppender";
import Logger from './Logger';
import type { LoggingLevelType } from "./LoggingLevel";
import LoggingLevelMapping from "./LoggingLevelMapping";
import { Threshold } from "./Threshold";
import ConsoleAppender from "./appenders/ConsoleAppender";
import Disposable from "../lang/disposables/Disposable";
type Category = string;
export default class LoggerFactory {
private static readonly _appenders: Set<IAppender> = new Set();
private static readonly _threshold: Threshold = new Threshold();
private static readonly _loggers: Map<Category, Logger> = new Map();
static {
this.applyConsoleAppender();
}
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 applyAppender(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.applyAppender(new ConsoleAppender());
}
}

View File

@@ -0,0 +1,12 @@
export enum LoggingLevel {
Off = -1,
Debug = 10,
Trace = 20,
Silly = 30,
Info = 40,
Warn = 50,
Error = 60,
All = 70
}
export type LoggingLevelType = 'Off' | 'Debug' | 'Trace' | 'Info' | 'Silly' | 'Warn' | 'Error' | 'All';

View File

@@ -0,0 +1,52 @@
import {assertUnreachable} from '@techniker-me/tools';
import {LoggingLevel, type 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);
}
}
}

19
src/logger/Threshold.ts Normal file
View File

@@ -0,0 +1,19 @@
import Defaults from '../Defaults';
import { Subject } from '../lang/observables';
import { LoggingLevel } from './LoggingLevel';
export class Threshold {
private _threshold: Subject<LoggingLevel>;
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;
}
}

View File

@@ -0,0 +1,63 @@
import {LoggingLevel, type LoggingLevelType} from '../LoggingLevel';
import type ILogMessage from './LogMessage';
export type AppenderOptions = {
domain?: string;
};
export default class Appender {
private readonly _logRecorderUrl: string = 'https://logserver.techniker.me/api/logs';
// @ts-ignore
private readonly _domain: string = typeof globalThis !== 'undefined' ? (globalThis.location?.hostname ?? '') : '';
private readonly _logMessageQueue: ILogMessage[] = [];
private _pendingPostLogMessagePromise: Promise<Response | undefined> | undefined = undefined;
constructor(logRecorderUrl: string, {domain}: AppenderOptions) {
this._logRecorderUrl = logRecorderUrl;
this._domain = domain ?? this._domain;
}
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<void> {
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: ILogMessage): void {
this._logMessageQueue.push(logMessage);
}
}

View File

@@ -0,0 +1,12 @@
import Appender from './Appender';
import type { AppenderOptions } from './Appender';
export default class AppnederFactory {
public static createRemoteAppender(remoteAppenderUrl: string, {domain}: AppenderOptions): Appender {
return new Appender(remoteAppenderUrl, {domain: domain ?? ''});
}
private constructor() {
throw new Error('AppenderFactory is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,31 @@
import {LoggingLevel, type LoggingLevelType} from '../LoggingLevel';
import LoggingLevelMapping from '../LoggingLevelMapping';
import type {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);
}
}
}

View File

@@ -0,0 +1,5 @@
import type { LoggingLevelType } from '../LoggingLevel';
export interface IAppender {
log(timestamp: string, level: LoggingLevelType, category: string, message: string): void;
}

View File

@@ -0,0 +1,6 @@
export default interface ILogMessage {
timestamp: string;
level: string;
category: string;
message: string;
}

View File

@@ -0,0 +1,55 @@
import type {LoggingLevel, LoggingLevelType} from '../LoggingLevel';
import type {IAppender} from './IAppender';
import type ILogMessage from './LogMessage';
export default class TechnikerMeAppender implements IAppender {
private readonly _logRecorderUrl: string = 'https://logserver.techniker.me/api/logs';
// @ts-ignore
private readonly _domain: string = typeof globalThis !== 'undefined' ? (globalThis.location?.hostname ?? '') : '';
private readonly _logMessageQueue: ILogMessage[] = [];
private _pendingPostLogMessagePromise: Promise<Response | undefined> | 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<void> {
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: ILogMessage): void {
this._logMessageQueue.push(logMessage);
}
}

View File

@@ -0,0 +1,95 @@
import assertUnreachable from '../../lang/assertUnreachable';
import type express from 'express';
export type HttpRoute = string;
export type RoutePath = string;
export type RouteHandler = (request: express.Request, response: express.Response) => void | Promise<void>;
export class HttpRouteManager {
private readonly _staticGETRoutes: Record<HttpRoute, RoutePath>;
private readonly _getRoutes: Record<HttpRoute, RouteHandler>;
private readonly _postRoutes: Record<HttpRoute, RouteHandler>;
private readonly _putRoutes: Record<HttpRoute, RouteHandler>;
private readonly _deleteRoutes: Record<HttpRoute, RouteHandler>;
constructor() {
this._staticGETRoutes = {};
this._getRoutes = {};
this._postRoutes = {};
this._putRoutes = {};
this._deleteRoutes = {};
}
public registerStaticRoute(routePath: RoutePath, destination: string) {
if (this._staticGETRoutes[routePath]) {
throw new Error(`Error: Static route [${routePath}] is already defined`);
}
this._staticGETRoutes[routePath] = destination;
}
public register(verb: 'GET' | 'POST' | 'PUT' | 'DELETE', route: string, handler: RouteHandler) {
const isRouteDefined = (routes: Record<HttpRoute, RouteHandler>, routePath: string) => !!routes[routePath];
switch (verb) {
case 'GET':
if (isRouteDefined(this._getRoutes, route)) {
throw new Error(`Error: Route [${route}] is already defined`);
}
this._getRoutes[route] = handler;
break;
case 'POST':
if (isRouteDefined(this._postRoutes, route)) {
throw new Error(`Error: Route [${route}] is already defined`);
}
this._postRoutes[route] = handler;
break;
case 'PUT':
if (isRouteDefined(this._putRoutes, route)) {
throw new Error(`Error: Route [${route}] is already defined`);
}
this._putRoutes[route] = handler;
break;
case 'DELETE':
if (isRouteDefined(this._deleteRoutes, route)) {
throw new Error(`Error: Route [${route}] is already defined`);
}
this._deleteRoutes[route] = handler;
break;
default:
assertUnreachable(verb);
}
}
public getStaticGETRoutes(): Record<HttpRoute, RoutePath> {
return this._staticGETRoutes;
}
public getGETRoutes(): Record<HttpRoute, RouteHandler> {
return this._getRoutes;
}
public getPOSTRoutes(): Record<HttpRoute, RouteHandler> {
return this._postRoutes;
}
public getPUTRoutes(): Record<HttpRoute, RouteHandler> {
return this._putRoutes;
}
public getDELETERoutes(): Record<HttpRoute, RouteHandler> {
return this._deleteRoutes;
}
}

View File

@@ -0,0 +1,56 @@
import express, {type Express} from 'express';
import type {HttpRouteManager} from './HttpRouteManger';
import path from 'path';
import morgan from 'morgan';
type Routes = Record<string, RouteHandler>;
type RouteHandler = (req: express.Request, res: express.Response) => void | Promise<void>;
export class HttpServer {
private _app: Express;
constructor(private readonly routeManager: HttpRouteManager) {
this._app = express();
this._app.use(morgan('dev'));
this.initialize();
}
public start(port: number) {
this._app.listen(port, () => console.log('Server listening on [%o]', port));
}
private initialize() {
this.applyMiddleware();
this.applyStaticRoutes();
this.applyRoutes('get', this.routeManager.getGETRoutes());
this.applyRoutes('post', this.routeManager.getPOSTRoutes());
this.applyRoutes('put', this.routeManager.getPUTRoutes());
this.applyRoutes('delete', this.routeManager.getDELETERoutes());
}
private applyRoutes(httpVerb: 'get' | 'post' | 'put' | 'delete', routes: Routes) {
Object.entries(routes).forEach(([route, handler]) => this._app[httpVerb as keyof typeof this._app](route, handler));
}
private applyStaticRoutes() {
Object.entries(this.routeManager.getStaticGETRoutes()).forEach(([route, destination]) => {
this._app.use(
route,
express.static(path.resolve(destination), {
setHeaders: (res, filePath) => {
console.log(filePath);
if (filePath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
}
})
);
});
}
private applyMiddleware() {
this._app.use(express.json());
this._app.use(express.urlencoded({extended: true}));
}
}