Add moment.js as a dependency, enhance TypeScript configuration to exclude scripts, and introduce new examples for dependency injection and health checks.
This commit is contained in:
0
examples/cluster.json
Normal file
0
examples/cluster.json
Normal file
@@ -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<IMessageQueue>(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);
|
||||||
61
examples/di.json
Normal file
61
examples/di.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -25,5 +25,8 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"moment": "2.30.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
scripts/Runner.js
Normal file
81
scripts/Runner.js
Normal file
@@ -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();
|
||||||
5
scripts/npm-install.js
Normal file
5
scripts/npm-install.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import runner from './Runner.js';
|
||||||
|
|
||||||
|
runner.runCommands(['node --version', 'npm --version', 'npm install --no-save'], () => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
3
scripts/version.js
Normal file
3
scripts/version.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import packageJSON from '../package.json' with {type: 'json'};
|
||||||
|
|
||||||
|
process.stdout.write(packageJSON.version);
|
||||||
105
src/di/ConfigurationReader.ts
Normal file
105
src/di/ConfigurationReader.ts
Normal file
@@ -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<void> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ export class DependencyManager implements IDependencyManager {
|
|||||||
return candidates[0] as IDependencyProvider;
|
return candidates[0] as IDependencyProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async instantiateType(type: Type): Promise<unknown> {
|
public async instantiateType<T = unknown>(type: Type): Promise<T> {
|
||||||
if (!(type instanceof Type)) {
|
if (!(type instanceof Type)) {
|
||||||
throw new Error('Type must be an instance of Type');
|
throw new Error('Type must be an instance of Type');
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,8 @@ export class DependencyManager implements IDependencyManager {
|
|||||||
const key = type.toURN();
|
const key = type.toURN();
|
||||||
|
|
||||||
// Check for circular dependencies
|
// 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(' -> ');
|
const pendingChain = this._pending.map(t => t.toString()).join(' -> ');
|
||||||
throw new Error(`Failed to resolve ${type} due to circular dependency: ${pendingChain}`);
|
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);
|
this._pending.push(type);
|
||||||
|
|
||||||
try {
|
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
|
// Get dependencies for this type
|
||||||
const dependencies = this._injections.get(key) || [];
|
const dependencies = this._injections.get(key) || [];
|
||||||
|
|
||||||
@@ -135,7 +146,16 @@ export class DependencyManager implements IDependencyManager {
|
|||||||
|
|
||||||
// Load the module
|
// Load the module
|
||||||
const module = await this._moduleLoader(type.getType());
|
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) {
|
if (!Constructor) {
|
||||||
throw new Error(`Could not load type "${type.getType()}"`);
|
throw new Error(`Could not load type "${type.getType()}"`);
|
||||||
@@ -144,11 +164,6 @@ export class DependencyManager implements IDependencyManager {
|
|||||||
// Instantiate with resolved dependencies
|
// Instantiate with resolved dependencies
|
||||||
const instance = this.injectDependencies(Constructor, resolvedDependencies);
|
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;
|
return instance;
|
||||||
} finally {
|
} finally {
|
||||||
// Remove from pending stack
|
// Remove from pending stack
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {Milliseconds} from '../types/Units';
|
|||||||
import type {IDependencyManager} from './IDependencyManager';
|
import type {IDependencyManager} from './IDependencyManager';
|
||||||
import {Lifecycle} from './Lifecycle';
|
import {Lifecycle} from './Lifecycle';
|
||||||
import {Type} from './Type';
|
import {Type} from './Type';
|
||||||
|
import {NamedType} from './NamedType';
|
||||||
|
|
||||||
interface IStartable {
|
interface IStartable {
|
||||||
start(): Promise<void> | void;
|
start(): Promise<void> | void;
|
||||||
@@ -33,7 +34,9 @@ export class DependencyProvider implements IDependencyProvider {
|
|||||||
throw new Error('Must provide a valid type');
|
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<unknown> {
|
public async provide(type: Type): Promise<unknown> {
|
||||||
@@ -55,7 +58,12 @@ export class DependencyProvider implements IDependencyProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _createInstance(): Promise<unknown> {
|
private async _createInstance(): Promise<unknown> {
|
||||||
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 (this._lifecycle === Lifecycle.Service) {
|
||||||
if (!isStartable(instance)) {
|
if (!isStartable(instance)) {
|
||||||
|
|||||||
16
src/di/IServiceDefinition.ts
Normal file
16
src/di/IServiceDefinition.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
12
src/di/JSONConfigurationLoader.ts
Normal file
12
src/di/JSONConfigurationLoader.ts
Normal file
@@ -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<object> {
|
||||||
|
const filePath = resolve(path);
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
return JSON.parse(fileContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,13 @@ export enum Lifecycle {
|
|||||||
Instance = 2
|
Instance = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LifecycleType = 'signleton' | 'service' | 'instance';
|
export type LifecycleType = 'singleton' | 'service' | 'instance';
|
||||||
|
|
||||||
export class LifecycleMapping {
|
export class LifecycleMapping {
|
||||||
public static convertLifecycleToLifecycleType(lifecycle: Lifecycle): LifecycleType {
|
public static convertLifecycleToLifecycleType(lifecycle: Lifecycle): LifecycleType {
|
||||||
switch (lifecycle) {
|
switch (lifecycle) {
|
||||||
case Lifecycle.Singleton:
|
case Lifecycle.Singleton:
|
||||||
return 'signleton';
|
return 'singleton';
|
||||||
case Lifecycle.Service:
|
case Lifecycle.Service:
|
||||||
return 'service';
|
return 'service';
|
||||||
case Lifecycle.Instance:
|
case Lifecycle.Instance:
|
||||||
@@ -24,7 +24,7 @@ export class LifecycleMapping {
|
|||||||
|
|
||||||
public static convertLifecycleTypeToLifecycle(lifecycleType: LifecycleType): Lifecycle {
|
public static convertLifecycleTypeToLifecycle(lifecycleType: LifecycleType): Lifecycle {
|
||||||
switch (lifecycleType) {
|
switch (lifecycleType) {
|
||||||
case 'signleton':
|
case 'singleton':
|
||||||
return Lifecycle.Singleton;
|
return Lifecycle.Singleton;
|
||||||
case 'service':
|
case 'service':
|
||||||
return Lifecycle.Service;
|
return Lifecycle.Service;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export class Type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public equals(other: Type): boolean {
|
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() {
|
public toURN() {
|
||||||
|
|||||||
16
src/di/index.ts
Normal file
16
src/di/index.ts
Normal file
@@ -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';
|
||||||
56
src/env/ConfigurationProvider.ts
vendored
Normal file
56
src/env/ConfigurationProvider.ts
vendored
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<unknown> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/env/RuntimeConfigurationProvider.ts
vendored
Normal file
32
src/env/RuntimeConfigurationProvider.ts
vendored
Normal file
@@ -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<unknown> {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/example/Connection.ts
Normal file
27
src/example/Connection.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
console.log('[Connection] Disconnecting...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
console.log('[Connection] Disconnected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return 'Connection';
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/example/IConnection.ts
Normal file
5
src/example/IConnection.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface IConnection {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
4
src/example/IMessageQueue.ts
Normal file
4
src/example/IMessageQueue.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface IMessageQueue {
|
||||||
|
publish(topic: string, message: string): Promise<void>;
|
||||||
|
subscribe(topic: string, handler: (message: string) => void): Promise<void>;
|
||||||
|
}
|
||||||
30
src/example/MessageQueue.ts
Normal file
30
src/example/MessageQueue.ts
Normal file
@@ -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<void> {
|
||||||
|
await this._connection.connect();
|
||||||
|
console.log(`Publishing to [${topic}]: ${message}`);
|
||||||
|
await this._connection.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(topic: string, handler: (message: string) => void): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/health/HealthCheck.ts
Normal file
143
src/health/HealthCheck.ts
Normal file
@@ -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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/health/HttpHealthCheck.ts
Normal file
137
src/health/HttpHealthCheck.ts
Normal file
@@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean>((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}]]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/index.ts
22
src/index.ts
@@ -1,14 +1,10 @@
|
|||||||
export type {IDependencyProvider} from './di/IDependencyProvider';
|
// Main exports for platform-ts library
|
||||||
export type {IDependencyManager} from './di/IDependencyManager';
|
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 {default as IDisposable} from './lang/IDisposable';
|
||||||
export type {LifecycleType} from './di/Lifecycle';
|
export {default as assertUnreachable} from './lang/assertUnreachable';
|
||||||
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';
|
|
||||||
|
|||||||
53
src/lang/Events.ts
Normal file
53
src/lang/Events.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {EventEmitter} from 'events';
|
||||||
|
import type IDisposable from './IDisposable';
|
||||||
|
import Disposable from './Disposable';
|
||||||
|
|
||||||
|
type EventListener = (...args: unknown[]) => void | Promise<void>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,5 +33,5 @@
|
|||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules/**/*", "test/**/*"]
|
"exclude": ["node_modules/**/*", "test/**/*", "scripts/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user