From 3429df650f42f3a54f2c388c0bb3d2ce50bae7bc Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 26 Oct 2025 09:39:46 -0400 Subject: [PATCH] Add moment.js as a dependency, enhance TypeScript configuration to exclude scripts, and introduce new examples for dependency injection and health checks. --- examples/cluster.json | 0 examples/di-example.ts | 33 +++++- examples/di.json | 61 ++++++++++ package.json | 3 + scripts/Runner.js | 81 ++++++++++++++ scripts/npm-install.js | 5 + scripts/version.js | 3 + src/di/ConfigurationReader.ts | 105 +++++++++++++++++ src/di/DependencyManager.ts | 31 +++-- src/di/DependencyProvider.ts | 12 +- src/di/IServiceDefinition.ts | 16 +++ src/di/JSONConfigurationLoader.ts | 12 ++ src/di/Lifecycle.ts | 6 +- src/di/Type.ts | 3 +- src/di/index.ts | 16 +++ src/env/ConfigurationProvider.ts | 56 ++++++++++ src/env/RuntimeConfigurationProvider.ts | 32 ++++++ src/example/Connection.ts | 27 +++++ src/example/IConnection.ts | 5 + src/example/IMessageQueue.ts | 4 + src/example/MessageQueue.ts | 30 +++++ src/health/HealthCheck.ts | 143 ++++++++++++++++++++++++ src/health/HttpHealthCheck.ts | 137 +++++++++++++++++++++++ src/index.ts | 22 ++-- src/lang/Events.ts | 53 +++++++++ tsconfig.json | 2 +- 26 files changed, 868 insertions(+), 30 deletions(-) create mode 100644 examples/cluster.json create mode 100644 examples/di.json create mode 100644 scripts/Runner.js create mode 100644 scripts/npm-install.js create mode 100644 scripts/version.js create mode 100644 src/di/ConfigurationReader.ts create mode 100644 src/di/IServiceDefinition.ts create mode 100644 src/di/JSONConfigurationLoader.ts create mode 100644 src/di/index.ts create mode 100644 src/env/ConfigurationProvider.ts create mode 100644 src/env/RuntimeConfigurationProvider.ts create mode 100644 src/example/Connection.ts create mode 100644 src/example/IConnection.ts create mode 100644 src/example/IMessageQueue.ts create mode 100644 src/example/MessageQueue.ts create mode 100644 src/health/HealthCheck.ts create mode 100644 src/health/HttpHealthCheck.ts create mode 100644 src/lang/Events.ts diff --git a/examples/cluster.json b/examples/cluster.json new file mode 100644 index 0000000..e69de29 diff --git a/examples/di-example.ts b/examples/di-example.ts index 7580ce2..a21c796 100644 --- a/examples/di-example.ts +++ b/examples/di-example.ts @@ -1,3 +1,32 @@ -import {DependencyManager} from '../src'; +import {ConfigurationReader, DependencyManager, JSONConfigurationLoader, Type} from '../src/di'; +import type {IMessageQueue} from '../src/example/IMessageQueue'; -const dependencyManager = new DependencyManager(async path => await import(path)); +async function main() { + const moduleLoader = async (path: string) => await import(`../${path}.ts`); + const dependencyManager = new DependencyManager(moduleLoader); + const configurationReader = new ConfigurationReader(dependencyManager, new JSONConfigurationLoader()); + + await configurationReader.load('./examples/di.json'); + + // Instantiate all eager types + const eagerTypes = await dependencyManager.getEagerTypes(); + + for (const type of eagerTypes) { + await dependencyManager.instantiateType(type); + } + + // Resolve the MessageQueue + const messageQueueType = new Type('src/example/IMessageQueue'); + const messageQueue = await dependencyManager.instantiateType(messageQueueType); + + messageQueue.subscribe('test-topic', (message: string) => { + console.log('[MessageQueue] [Listener] Received message:', message); + }); + + // Use the service + await messageQueue.publish('test-topic', 'Hello, DI!'); + + console.log('[MessageQueue] [Publisher] Message published successfully!'); +} + +main().catch(console.error); \ No newline at end of file diff --git a/examples/di.json b/examples/di.json new file mode 100644 index 0000000..685999b --- /dev/null +++ b/examples/di.json @@ -0,0 +1,61 @@ +[ + { + "name": "connection", + "class": "src/env/ConfigurationProvider", + "lifecycle": "singleton" + }, + { + "name": "mq.heartbeat.interval", + "class": "src/env/ConfigurationProvider", + "lifecycle": "singleton" + }, + { + "name": "mq.heartbeat.timeout", + "class": "src/env/ConfigurationProvider", + "lifecycle": "singleton" + }, + { + "name": "environment.version", + "class": "src/env/ConfigurationProvider", + "lifecycle": "singleton" + }, + { + "class": "src/example/Connection", + "instanceType": "src/example/IConnection", + "lifecycle": "singleton", + "inject": [ + { + "name": "connection", + "class": "src/env/ConfigurationProvider" + } + ] + }, + { + "class": "src/example/MessageQueue", + "instanceType": "src/example/IMessageQueue", + "inject": ["src/example/IConnection"], + "eager": true + }, + { + "class": "src/health/HealthCheck", + "inject": [ + { + "name": "environment.version", + "class": "src/env/ConfigurationProvider" + }, + { + "name": "app", + "class": "src/env/ConfigurationProvider" + } + ] + }, + { + "class": "src/health/HttpHealthCheck", + "inject": [ + { + "name": "environment.version", + "class": "src/env/ConfigurationProvider" + } + ] + } +] diff --git a/package.json b/package.json index 6c575ff..c489cb5 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ }, "peerDependencies": { "typescript": "5.9.3" + }, + "dependencies": { + "moment": "2.30.1" } } diff --git a/scripts/Runner.js b/scripts/Runner.js new file mode 100644 index 0000000..c5da43f --- /dev/null +++ b/scripts/Runner.js @@ -0,0 +1,81 @@ +import fs from 'fs'; +import path from 'path'; +import {spawn} from 'child_process'; + +const node = process.execPath; +const nodeDir = path.dirname(node); +let npm = path.join(nodeDir, 'npm.cmd'); + +if (!fs.existsSync(npm)) { + const alternateNpm = path.join(nodeDir, 'npm'); + + if (fs.existsSync(alternateNpm)) { + npm = alternateNpm; + } +} + +class Runner { + _node = process.execPath; + _nodeDir = path.dirname(this._node); + _npm; + + constructor() { + this._npm = path.join(this._nodeDir, 'npm.cmd'); + + if (!fs.existsSync(this._npm)) { + const alternateNpm = path.join(this._nodeDir, 'npm'); + + if (fs.existsSync(alternateNpm)) { + this._npm = alternateNpm; + } + } + } + + runCommands(commands, done) { + let command = commands[0]; + + if (typeof command === 'string') { + command = command.split(' '); + } + + if (command.length > 0) { + if (command[0] === 'npm') { + command[0] = this._npm; + } else if (command[0] === 'node') { + command[0] = this._node; + } + } + + this.run(command, () => { + if (commands.length > 1) { + this.runCommands(commands.slice(1), done); + } else { + if (done) { + done(); + } + } + }); + } + + run(command, next) { + const childProcess = spawn(command[0], command.slice(1), {stdio: 'inherit'}); + + childProcess.on('error', error => { + console.error('Error [%o]', error); + + process.exit(40); + }); + + childProcess.on('close', code => { + if (code !== 0) { + console.error('Command [%o] exited with code [%d]', command, code); + + process.exit(code); + } + + next(); + }); + } +} + +export default new Runner(); diff --git a/scripts/npm-install.js b/scripts/npm-install.js new file mode 100644 index 0000000..fa474be --- /dev/null +++ b/scripts/npm-install.js @@ -0,0 +1,5 @@ +import runner from './Runner.js'; + +runner.runCommands(['node --version', 'npm --version', 'npm install --no-save'], () => { + process.exit(0); +}); diff --git a/scripts/version.js b/scripts/version.js new file mode 100644 index 0000000..f7b91bd --- /dev/null +++ b/scripts/version.js @@ -0,0 +1,3 @@ +import packageJSON from '../package.json' with {type: 'json'}; + +process.stdout.write(packageJSON.version); diff --git a/src/di/ConfigurationReader.ts b/src/di/ConfigurationReader.ts new file mode 100644 index 0000000..7f78262 --- /dev/null +++ b/src/di/ConfigurationReader.ts @@ -0,0 +1,105 @@ +import type {IDependencyManager} from './IDependencyManager'; +import type {IConfigurationLoader} from './IConfigurationLoader'; +import {Type} from './Type'; +import {NamedType} from './NamedType'; +import type {LifecycleType} from './Lifecycle'; +import {DependencyProvider} from './DependencyProvider'; +import {LifecycleMapping} from './Lifecycle'; +import type {IDependencyDefinition, IServiceDefinition} from './IServiceDefinition'; + +export class ConfigurationReader { + private readonly _dependencyManager: IDependencyManager; + private readonly _configurationLoader: IConfigurationLoader; + + constructor(dependencyManager: IDependencyManager, configurationLoader: IConfigurationLoader) { + if (!dependencyManager) { + throw new Error('Dependency manager is required'); + } + + if (!configurationLoader) { + throw new Error('Configuration loader is required'); + } + + this._dependencyManager = dependencyManager; + this._configurationLoader = configurationLoader; + } + + public async load(path: string): Promise { + const definitions = await this._configurationLoader.load(path); + + if (!Array.isArray(definitions) || definitions.length === 0) { + throw new Error('Definitions must be an array and not empty'); + } + + for (const [idx, definition] of definitions.entries()) { + if (definition.include && typeof definition.include === 'string') { + const importDefinitions = await this._configurationLoader.load(definition.include); + + if (!Array.isArray(importDefinitions)) { + throw new Error('Import definitions must be an array'); + } + + for (const importDefinition of importDefinitions) { + await this.load(importDefinition); + } + + continue; + } + + if (!definition.class || typeof definition.class !== 'string') { + throw new Error(`Definition must define a class (index: [${idx}], definition [${JSON.stringify(definition)}]`); + } + + const type = this.getType(definition); + const dependencies: Type[] = []; + + if (definition.inject) { + if (!Array.isArray(definition.inject)) { + throw new Error(`Injection must be an array (index: ${idx}, definition: ${JSON.stringify(definition)})`); + } + + for (const dependency of definition.inject) { + let depType: Type; + + if (typeof dependency === 'object') { + depType = this.getType(dependency as IDependencyDefinition); + } else { + depType = new Type(dependency); + } + + dependencies.push(depType); + } + } + + this._dependencyManager.defineDependencies(type, dependencies); + + // Only create a provider if there's a separate instance type or special lifecycle + if (definition.instanceType || definition.lifecycle === 'singleton' || definition.lifecycle === 'service') { + const instanceType = definition.instanceType ? new Type(definition.instanceType) : type; + const lifecycle: LifecycleType = definition.lifecycle || 'instance'; + const provider = new DependencyProvider(this._dependencyManager, type, instanceType, LifecycleMapping.convertLifecycleTypeToLifecycle(lifecycle)); + this._dependencyManager.addProvider(provider); + } + + if (definition.eager) { + this._dependencyManager.addEagerType(type); + } + } + } + + private getType(definition: IDependencyDefinition): Type { + if (!definition.class || typeof definition.class !== 'string') { + throw new Error(`Definition must define a class (${JSON.stringify(definition)})`); + } + + if (definition.name !== undefined) { + return new NamedType(definition.name, definition.class); + } + + return new Type(definition.class); + } + + toString(): string { + return 'ConfigurationReader'; + } +} diff --git a/src/di/DependencyManager.ts b/src/di/DependencyManager.ts index 796e659..471813e 100644 --- a/src/di/DependencyManager.ts +++ b/src/di/DependencyManager.ts @@ -99,7 +99,7 @@ export class DependencyManager implements IDependencyManager { return candidates[0] as IDependencyProvider; } - public async instantiateType(type: Type): Promise { + public async instantiateType(type: Type): Promise { if (!(type instanceof Type)) { throw new Error('Type must be an instance of Type'); } @@ -107,7 +107,8 @@ export class DependencyManager implements IDependencyManager { const key = type.toURN(); // Check for circular dependencies - if (this._pending.includes(type)) { + const isAlreadyPending = this._pending.some(t => t.equals(type)); + if (isAlreadyPending) { const pendingChain = this._pending.map(t => t.toString()).join(' -> '); throw new Error(`Failed to resolve ${type} due to circular dependency: ${pendingChain}`); } @@ -115,6 +116,16 @@ export class DependencyManager implements IDependencyManager { this._pending.push(type); try { + // Check if there's a provider for this type (only if not already being instantiated) + const providers = this._providers.filter(provider => provider.canProvide(type)); + if (providers.length > 0) { + if (providers.length > 1) { + const candidateNames = providers.map(c => c.toString()).join(', '); + throw new Error(`Multiple providers for ${type}: ${candidateNames}`); + } + return await providers[0].provide(type) as T; + } + // Get dependencies for this type const dependencies = this._injections.get(key) || []; @@ -135,7 +146,16 @@ export class DependencyManager implements IDependencyManager { // Load the module const module = await this._moduleLoader(type.getType()); - const Constructor = 'default' in module ? module.default : module; + let Constructor; + + if ('default' in module) { + Constructor = module.default; + } else { + // Handle named exports - extract class name from type path + const typePath = type.getType(); + const className = typePath.split('/').pop(); + Constructor = module[className as string]; + } if (!Constructor) { throw new Error(`Could not load type "${type.getType()}"`); @@ -144,11 +164,6 @@ export class DependencyManager implements IDependencyManager { // Instantiate with resolved dependencies const instance = this.injectDependencies(Constructor, resolvedDependencies); - // Handle if the instance is itself a provider - if (this.isProvider(instance)) { - return await (instance as IDependencyProvider).provide(type); - } - return instance; } finally { // Remove from pending stack diff --git a/src/di/DependencyProvider.ts b/src/di/DependencyProvider.ts index 1e39981..9ebea1f 100644 --- a/src/di/DependencyProvider.ts +++ b/src/di/DependencyProvider.ts @@ -3,6 +3,7 @@ import type {Milliseconds} from '../types/Units'; import type {IDependencyManager} from './IDependencyManager'; import {Lifecycle} from './Lifecycle'; import {Type} from './Type'; +import {NamedType} from './NamedType'; interface IStartable { start(): Promise | void; @@ -33,7 +34,9 @@ export class DependencyProvider implements IDependencyProvider { throw new Error('Must provide a valid type'); } - return type.equals(this._type) && this._type.equals(type); + // Only provide the instance type to avoid circular dependencies + // when the provider tries to instantiate its own concrete type + return type.equals(this._instanceType); } public async provide(type: Type): Promise { @@ -55,7 +58,12 @@ export class DependencyProvider implements IDependencyProvider { } private async _createInstance(): Promise { - const instance = await this._dependencyManager.instantiateType(this._instanceType); + // If _type is a NamedType, instantiate the base type to avoid circular dependency + const typeToInstantiate = this._type instanceof NamedType + ? new Type(this._type.getType()) + : this._type; + + const instance = await this._dependencyManager.instantiateType(typeToInstantiate); if (this._lifecycle === Lifecycle.Service) { if (!isStartable(instance)) { diff --git a/src/di/IServiceDefinition.ts b/src/di/IServiceDefinition.ts new file mode 100644 index 0000000..6383f82 --- /dev/null +++ b/src/di/IServiceDefinition.ts @@ -0,0 +1,16 @@ +import type {LifecycleType} from './Lifecycle'; + +export interface IDependencyDefinition { + class?: string; + name?: string; +} + +export interface IServiceDefinition { + class: string; + name?: string; + inject?: (string | IDependencyDefinition)[]; + instanceType?: string; + lifecycle?: LifecycleType; + eager?: boolean; + include?: string; +} diff --git a/src/di/JSONConfigurationLoader.ts b/src/di/JSONConfigurationLoader.ts new file mode 100644 index 0000000..d05f9de --- /dev/null +++ b/src/di/JSONConfigurationLoader.ts @@ -0,0 +1,12 @@ +import type {IConfigurationLoader} from './IConfigurationLoader'; +import fs from 'fs'; +import {resolve} from 'path'; + +export class JSONConfigurationLoader implements IConfigurationLoader { + async load(path: string): Promise { + const filePath = resolve(path); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + return JSON.parse(fileContent); + } +} diff --git a/src/di/Lifecycle.ts b/src/di/Lifecycle.ts index d2155d5..f254083 100644 --- a/src/di/Lifecycle.ts +++ b/src/di/Lifecycle.ts @@ -6,13 +6,13 @@ export enum Lifecycle { Instance = 2 } -export type LifecycleType = 'signleton' | 'service' | 'instance'; +export type LifecycleType = 'singleton' | 'service' | 'instance'; export class LifecycleMapping { public static convertLifecycleToLifecycleType(lifecycle: Lifecycle): LifecycleType { switch (lifecycle) { case Lifecycle.Singleton: - return 'signleton'; + return 'singleton'; case Lifecycle.Service: return 'service'; case Lifecycle.Instance: @@ -24,7 +24,7 @@ export class LifecycleMapping { public static convertLifecycleTypeToLifecycle(lifecycleType: LifecycleType): Lifecycle { switch (lifecycleType) { - case 'signleton': + case 'singleton': return Lifecycle.Singleton; case 'service': return Lifecycle.Service; diff --git a/src/di/Type.ts b/src/di/Type.ts index 7f275e4..59dc9ac 100644 --- a/src/di/Type.ts +++ b/src/di/Type.ts @@ -10,7 +10,8 @@ export class Type { } public equals(other: Type): boolean { - return other instanceof Type && this.getType() === other.getType(); + // Ensure Type only equals Type, not subclasses like NamedType + return other instanceof Type && other.constructor === this.constructor && this.getType() === other.getType(); } public toURN() { diff --git a/src/di/index.ts b/src/di/index.ts new file mode 100644 index 0000000..c530ea7 --- /dev/null +++ b/src/di/index.ts @@ -0,0 +1,16 @@ +export type {IDependencyProvider} from './IDependencyProvider'; +export type {IDependencyManager} from './IDependencyManager'; +export type {default as IDisposable} from '../lang/IDisposable'; +export type {LifecycleType} from './Lifecycle'; +export type {IInject} from './IInject'; +export type {IConfigurationLoader} from './IConfigurationLoader'; +export {Type} from './Type'; +export {NamedType} from './NamedType'; +export {DependencyManager} from './DependencyManager'; +export {DependencyProvider} from './DependencyProvider'; +export {InstanceProvider} from './InstanceProvider'; +export {IntegerConstantProvider} from './IntegerConstantProvider'; +export {StringConstantProvider} from './StringConstantProvider'; +export {Inject} from './Inject'; +export {ConfigurationReader} from './ConfigurationReader'; +export {JSONConfigurationLoader} from './JSONConfigurationLoader'; diff --git a/src/env/ConfigurationProvider.ts b/src/env/ConfigurationProvider.ts new file mode 100644 index 0000000..1cea5f3 --- /dev/null +++ b/src/env/ConfigurationProvider.ts @@ -0,0 +1,56 @@ +import type {IDependencyProvider} from '../di/IDependencyProvider'; +import {NamedType} from '../di/NamedType'; +import {Type} from '../di/Type'; + +export class ConfigurationProvider implements IDependencyProvider { + private readonly _config: Record; + + constructor() { + this._config = { + connection: 'localhost:5672', + mq: { + heartbeat: { + interval: 30000, + timeout: 60000 + } + }, + environment: { + version: '1.0.0' + } + }; + } + + public canProvide(type: Type): boolean { + if (type instanceof NamedType) { + return type.getType() === 'src/env/ConfigurationProvider'; + } + + return false; + } + + public async provide(type: NamedType): Promise { + const name = type.getName(); + + if (name === '') { + return this._config; + } + + const props = name.split('.'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = this._config; + + for (const prop of props) { + if (!(prop in current)) { + throw new Error(`Config path not found: ${name}`); + } + current = current[prop]; + } + + return current; + } + + public toString(): string { + return 'ConfigurationProvider'; + } +} diff --git a/src/env/RuntimeConfigurationProvider.ts b/src/env/RuntimeConfigurationProvider.ts new file mode 100644 index 0000000..99dfd60 --- /dev/null +++ b/src/env/RuntimeConfigurationProvider.ts @@ -0,0 +1,32 @@ +import {type IDependencyProvider, Type} from '../di'; +import packageJSON from '../../package.json' with {type: 'json'}; + +export class RuntimeConfigurationProvider implements IDependencyProvider { + public canProvide(type: Type): boolean { + throw new Error('Method not implemented.'); + } + public async provide(type: Type): Promise { + const runtimeConfig = { + environment: process.env.NODE_ENV || 'development', + version: packageJSON.version, + server: {}, + client: {} + }; + const serverApp = process.env.SERVER_APP; + + if (serverApp) { + runtimeConfig.server = {app: serverApp}; + } + + const clientApp = process.env.CLIENT_APP; + + if (clientApp) { + runtimeConfig.client = {app: clientApp}; + } + + return runtimeConfig; + } + public toString(): string { + throw new Error('Method not implemented.'); + } +} diff --git a/src/example/Connection.ts b/src/example/Connection.ts new file mode 100644 index 0000000..e247f98 --- /dev/null +++ b/src/example/Connection.ts @@ -0,0 +1,27 @@ +import type {IConnection} from './IConnection'; + +export class Connection implements IConnection { + private readonly _connectionString: string; + + constructor(connectionString: string) { + console.log(`[Connection] Created with: ${connectionString}`); + this._connectionString = connectionString; + } + + public async connect(): Promise { + console.log(`[Connection] Connecting with ${this._connectionString}...`); + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 100)); + console.log('[Connection] Connected.'); + } + + public async disconnect(): Promise { + console.log('[Connection] Disconnecting...'); + await new Promise(resolve => setTimeout(resolve, 50)); + console.log('[Connection] Disconnected.'); + } + + public toString(): string { + return 'Connection'; + } +} diff --git a/src/example/IConnection.ts b/src/example/IConnection.ts new file mode 100644 index 0000000..c4cd6cc --- /dev/null +++ b/src/example/IConnection.ts @@ -0,0 +1,5 @@ +export interface IConnection { + connect(): Promise; + disconnect(): Promise; + toString(): string; +} diff --git a/src/example/IMessageQueue.ts b/src/example/IMessageQueue.ts new file mode 100644 index 0000000..6acdd9f --- /dev/null +++ b/src/example/IMessageQueue.ts @@ -0,0 +1,4 @@ +export interface IMessageQueue { + publish(topic: string, message: string): Promise; + subscribe(topic: string, handler: (message: string) => void): Promise; +} diff --git a/src/example/MessageQueue.ts b/src/example/MessageQueue.ts new file mode 100644 index 0000000..be73c23 --- /dev/null +++ b/src/example/MessageQueue.ts @@ -0,0 +1,30 @@ +import type {IMessageQueue} from './IMessageQueue'; +import type {IConnection} from './IConnection'; + +export class MessageQueue implements IMessageQueue { + private readonly _connection: IConnection; + + constructor(connection: IConnection) { + if (!connection) { + throw new Error('Connection is required'); + } + this._connection = connection; + } + + public async publish(topic: string, message: string): Promise { + await this._connection.connect(); + console.log(`Publishing to [${topic}]: ${message}`); + await this._connection.disconnect(); + } + + public async subscribe(topic: string, handler: (message: string) => void): Promise { + await this._connection.connect(); + console.log(`Subscribing to [${topic}]`); + // In a real implementation, you'd keep the connection open and listen for messages. + // Here we'll just simulate receiving one message. + setTimeout(() => { + handler('Hello from the queue!'); + this._connection.disconnect(); + }, 500); + } +} diff --git a/src/health/HealthCheck.ts b/src/health/HealthCheck.ts new file mode 100644 index 0000000..93b619c --- /dev/null +++ b/src/health/HealthCheck.ts @@ -0,0 +1,143 @@ +import moment from 'moment'; +import type IDisposable from '../lang/IDisposable'; +import {Events} from '../lang/Events'; + +const checkForRecoverabilityTimeout = moment.duration(30, 'seconds'); + +interface IHealthCheckable { + checkForRecoverability(): Promise; +} + +export class HealthCheck { + private readonly _environment: string; + private readonly _app: string; + private readonly _version: string; + private readonly _regionName: string; + private readonly _zone: string; + private readonly _checks: IHealthCheckable[]; + private readonly _since: string; + private readonly _events: Events; + private _status: string; + private _enabled: boolean; + + constructor(environment: string, app: string, version: string, regionName: string, zone: string, ...checkForRecoverability: IHealthCheckable[]) { + this._environment = environment; + this._app = app; + this._version = version; + this._regionName = regionName; + this._zone = zone; + this._checks = checkForRecoverability; + this._since = moment.utc().toISOString(); + this._status = 'starting'; + this._events = new Events(); + this._enabled = true; + } + + public start() { + this._status = 'starting'; + this._events.emit('starting'); + this._events.emit('status-changed'); + + return null; + } + + public getInfo() { + return { + environment: this._environment, + app: this._app, + version: this._version, + region: this._regionName, + zone: this._zone, + since: this._since + }; + } + + public async checkHealth() { + if (this._status !== 'ok') { + return { + status: this._status, + environment: this._environment, + app: this._app, + version: this._version, + zone: this._zone + }; + } + + try { + const results = await Promise.all( + this._checks.map(async check => { + const isHealthy = await check.checkForRecoverability(); + + if (!isHealthy) { + console.warn('Health check failed [%s]', check); + } else { + console.debug('Health check passed [%s]', check); + } + + return isHealthy; + }) + ); + + const success = results.every(result => result === true); + + if (success === true) { + let status = this._status; + + if (status === 'ok' && !this._enabled) { + status = 'disabled'; + } + + return { + status, + environment: this._environment, + app: this._app, + version: this._version, + zone: this._zone + }; + } + + this._events.emit('health-check-failed'); + + return { + status: 'health-check-failed', + environment: this._environment, + app: this._app, + version: this._version, + zone: this._zone + }; + } catch (e) { + this._events.emit('health-check-failed'); + + console.warn('Health check failed', e); + + return { + status: 'health-check-failed', + environment: this._environment, + app: this._app, + version: this._version, + zone: this._zone, + reason: (e as Error).message + }; + } + } + + public on(event: string, listener: (...args: unknown[]) => void | Promise): IDisposable { + return this._events.on(event, listener); + } + + public markReady() { + this._status = 'ok'; + this._events.emit('ok'); + this._events.emit('status-changed'); + + return null; + } + + public enable(): void { + this._enabled = true; + } + + public disable(): void { + this._enabled = false; + } +} diff --git a/src/health/HttpHealthCheck.ts b/src/health/HttpHealthCheck.ts new file mode 100644 index 0000000..dc74566 --- /dev/null +++ b/src/health/HttpHealthCheck.ts @@ -0,0 +1,137 @@ +import type {Duration} from 'moment'; +import moment from 'moment'; +import {Agent as HttpAgent, request as httpRequest} from 'http'; +import {Agent as HttpsAgent, request as httpsRequest} from 'https'; + +interface IHttpHealthCheckConfig { + protocol: string; + hostname: string; + port: string; + keepalive?: boolean; + headers?: Record; + reconnectTimeout?: string; + requestTimeout?: string; +} + +export class HttpHealthCheck { + private readonly _protocol: string; + private readonly _hostname: string; + private readonly _port: string; + private readonly _keepalive: boolean; + private readonly _headers: Record; + private readonly _connectTimeout: Duration; + private readonly _requestTimeout: Duration; + + constructor(config: IHttpHealthCheckConfig) { + this._protocol = config.protocol; + this._hostname = config.hostname; + this._port = config.port; + this._keepalive = config.keepalive ?? false; + this._headers = config.headers ?? {}; + this._connectTimeout = moment.duration(config.reconnectTimeout ?? '30s'); + this._requestTimeout = moment.duration(config.requestTimeout ?? '30s'); + } + + public checkForRecoverability(): Promise { + const method = 'GET'; + const path = '/'; + const isHttps = this._protocol === 'https'; + const Agent = isHttps ? HttpsAgent : HttpAgent; + const request = isHttps ? httpsRequest : httpRequest; + + const options = { + hostname: this._hostname, + port: this._port, + path, + method, + headers: { + 'User-Agent': 'Health-Check/1.0', + Connection: 'close' + }, + timeout: this._connectTimeout.asMilliseconds(), + agent: new Agent({ + keepAlive: this._keepalive, + maxSockets: 1, + maxFreeSockets: 0 + }), + rejectUnauthorized: !isHttps + }; + + return new Promise((resolve, reject) => { + const req = request(options, res => { + let isHealthy = false; + + switch (res.statusCode) { + case 200: + case 301: + case 404: { + const allHeadersMatch = Object.keys(this._headers).every(headerKey => res.headers[headerKey] === this._headers[headerKey]); + + if (allHeadersMatch) { + isHealthy = true; + break; + } + + // Fall through to default case + console.warn( + 'HTTP health check to host [%s]:[%s] for [%s] [%s] failed - headers mismatch. Code [%s] headers [%j]', + this._hostname, + this._port, + method, + path, + res.statusCode, + res.headers + ); + break; + } + + default: + console.warn( + 'HTTP health check to host [%s]:[%s] for [%s] [%s] failed with code [%s] and headers [%j]', + this._hostname, + this._port, + method, + path, + res.statusCode, + res.headers + ); + } + + res.on('data', () => { + // Ignore response data + }); + + res.on('end', () => { + resolve(isHealthy); + }); + }); + + req.on('error', (e: Error & {code?: string}) => { + reject(e); + + if (e.code) { + console.error('HTTP health check failed: %s', e.code); + } else { + console.error('HTTP health check failed', e); + } + }); + + req.setTimeout(this._requestTimeout.asMilliseconds(), () => { + const err = new Error('HTTP health check timed out') as Error & {code?: string}; + err.code = 'ETIMEDOUT'; + + reject(err); + + console.warn('HTTP health check timed out'); + + req.destroy(); + }); + + req.end(); + }); + } + + public toString() { + return `HttpHealthCheck[protocol=[${this._protocol}],hostname=[${this._hostname}],port=[${this._port}]]`; + } +} diff --git a/src/index.ts b/src/index.ts index 6433395..bfd7563 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,10 @@ -export type {IDependencyProvider} from './di/IDependencyProvider'; -export type {IDependencyManager} from './di/IDependencyManager'; +// Main exports for platform-ts library +export * from './di'; +export * from './env/ConfigurationProvider'; +export * from './health/HealthCheck'; +export * from './health/HttpHealthCheck'; +export * from './lang/Events'; +export {default as Disposable} from './lang/Disposable'; +export {default as DisposableList} from './lang/DisposableList'; export type {default as IDisposable} from './lang/IDisposable'; -export type {LifecycleType} from './di/Lifecycle'; -export type {IInject} from './di/IInject'; -export type {IConfigurationLoader} from './di/IConfigurationLoader'; -export {Type} from './di/Type'; -export {NamedType} from './di/NamedType'; -export {DependencyManager} from './di/DependencyManager'; -export {DependencyProvider} from './di/DependencyProvider'; -export {InstanceProvider} from './di/InstanceProvider'; -export {IntegerConstantProvider} from './di/IntegerConstantProvider'; -export {StringConstantProvider} from './di/StringConstantProvider'; -export {Inject} from './di/Inject'; \ No newline at end of file +export {default as assertUnreachable} from './lang/assertUnreachable'; diff --git a/src/lang/Events.ts b/src/lang/Events.ts new file mode 100644 index 0000000..a1da784 --- /dev/null +++ b/src/lang/Events.ts @@ -0,0 +1,53 @@ +import {EventEmitter} from 'events'; +import type IDisposable from './IDisposable'; +import Disposable from './Disposable'; + +type EventListener = (...args: unknown[]) => void | Promise; + +export class Events { + private readonly _events: EventEmitter; + + constructor() { + this._events = new EventEmitter(); + } + + public on(event: string, listener: EventListener): IDisposable { + const wrappedListener = async (...eventArguments: unknown[]) => { + try { + await listener.apply(this, eventArguments); + } catch (e) { + console.error('Failed to process event [%s] with listener [%s]', event, listener, e); + } + }; + + this._events.on(event, wrappedListener); + + return new Disposable(() => { + this._events.removeListener(event, wrappedListener); + }); + } + + public once(event: string, listener: EventListener): IDisposable { + const wrappedListener = async (...eventArguments: unknown[]) => { + try { + await listener.apply(this, eventArguments); + } catch (e) { + console.error('Failed to process event [%s] with listener [%s]', event, listener, e); + } + }; + + this._events.once(event, wrappedListener); + + return new Disposable(() => { + this._events.removeListener(event, wrappedListener); + }); + } + + public emit(event: string, ...eventArguments: unknown[]): boolean { + return this._events.emit(event, ...eventArguments); + } + + public listenerCount(event: string): number { + return this._events.listenerCount(event); + } +} diff --git a/tsconfig.json b/tsconfig.json index 62c2b2e..952c5c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,5 @@ "noPropertyAccessFromIndexSignature": false }, "include": ["src/**/*"], - "exclude": ["node_modules/**/*", "test/**/*"] + "exclude": ["node_modules/**/*", "test/**/*", "scripts/**/*"] }