Compare commits

..

7 Commits

Author SHA1 Message Date
3429df650f Add moment.js as a dependency, enhance TypeScript configuration to exclude scripts, and introduce new examples for dependency injection and health checks. 2025-10-26 09:39:46 -04:00
5d9b77ef7d Add new exports for IInject, IConfigurationLoader, InstanceProvider, IntegerConstantProvider, StringConstantProvider, and Inject classes in index.ts to enhance dependency injection capabilities. 2025-10-25 17:58:12 -04:00
1710043b74 Fix method names in NamedType class to use correct TypeScript override syntax for toURN and toString methods. 2025-10-25 17:54:49 -04:00
00332dc6b1 Add Dependency Injection example and core interfaces. Implement Inject class for managing scopes and instances, along with InstanceProvider, IntegerConstantProvider, and StringConstantProvider for dependency resolution. 2025-10-25 17:51:44 -04:00
0de4b9314a Refactor Dependency Injection classes to use named imports for Type and interfaces. Change export statements from default to named exports for DependencyManager, DependencyProvider, IDependencyManager, IDependencyProvider, and NamedType classes. 2025-10-25 17:39:27 -04:00
e73c6e75e9 Update package configuration and TypeScript settings; add build script, versioning, and output directories. Refactor NamedType equality check for conciseness. Change import statements to use 'type' for IDisposable in Disposable and DisposableList classes. 2025-10-25 17:34:31 -04:00
975a543027 Add DependencyProvider and Lifecycle classes, along with utility types for time and data units. Implemented assertUnreachable for exhaustive checks in Lifecycle mapping. 2025-10-25 17:30:25 -04:00
39 changed files with 1242 additions and 32 deletions

0
examples/cluster.json Normal file
View File

32
examples/di-example.ts Normal file
View File

@@ -0,0 +1,32 @@
import {ConfigurationReader, DependencyManager, JSONConfigurationLoader, Type} from '../src/di';
import type {IMessageQueue} from '../src/example/IMessageQueue';
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
View 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"
}
]
}
]

View File

@@ -1,13 +1,15 @@
{
"name": "platform-ts",
"module": "index.ts",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"format": "prettier --write .",
"lint": "eslint --max-warnings 0 ./src",
"prelint:fix": "bun run format",
"lint:fix": "eslint --fix ./src"
"lint:fix": "eslint --fix ./src",
"build": "tsc"
},
"devDependencies": {
"@eslint/css": "0.13.0",
@@ -23,5 +25,8 @@
},
"peerDependencies": {
"typescript": "5.9.3"
},
"dependencies": {
"moment": "2.30.1"
}
}

81
scripts/Runner.js Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
import packageJSON from '../package.json' with {type: 'json'};
process.stdout.write(packageJSON.version);

View 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';
}
}

View File

@@ -1,13 +1,13 @@
import type IDependencyProvider from './IDependencyProvider';
import type {IDependencyProvider} from './IDependencyProvider';
import type IDisposable from '../lang/IDisposable';
import Disposable from '../lang/Disposable';
import Type from './Type';
import type IDependencyManager from './IDependencyManager';
import {Type} from './Type';
import type {IDependencyManager} from './IDependencyManager';
type Constructor<T = unknown> = new (...args: unknown[]) => T;
type ModuleLoader = (modulePath: string) => Promise<Constructor | {default: Constructor}>;
export default class DependencyManager implements IDependencyManager {
export class DependencyManager implements IDependencyManager {
private readonly _moduleLoader: ModuleLoader;
private readonly _types: Type[];
private readonly _eagerTypes: Type[];
@@ -99,7 +99,7 @@ export default class DependencyManager implements IDependencyManager {
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)) {
throw new Error('Type must be an instance of Type');
}
@@ -107,7 +107,8 @@ export default 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 default 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 default 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 default 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

View File

@@ -0,0 +1,92 @@
import type {IDependencyProvider} from './IDependencyProvider';
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> | void;
}
const SERVICE_START_TIMEOUT: Milliseconds = 30_000; // 30 seconds
function isStartable(obj: unknown): obj is IStartable {
return obj !== null && typeof obj === 'object' && 'start' in obj && typeof (obj as IStartable).start === 'function';
}
export class DependencyProvider implements IDependencyProvider {
private readonly _dependencyManager: IDependencyManager;
private readonly _type: Type;
private readonly _instanceType: Type;
private readonly _lifecycle: Lifecycle;
private _instancePromise?: Promise<unknown>;
constructor(dependencyManager: IDependencyManager, type: Type, instanceType: Type, lifecycle: Lifecycle) {
this._dependencyManager = dependencyManager;
this._type = type;
this._instanceType = instanceType;
this._lifecycle = lifecycle;
}
public canProvide(type: Type): boolean {
if (!(type instanceof Type)) {
throw new Error('Must provide a valid 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> {
if (!this.canProvide(type)) {
throw new Error(`Provider for ${this._type} cannot provide ${type}`);
}
if (this._instancePromise) {
return this._instancePromise;
}
const instancePromise = this._createInstance();
if (this._lifecycle === Lifecycle.Singleton || this._lifecycle === Lifecycle.Service) {
this._instancePromise = instancePromise;
}
return instancePromise;
}
private async _createInstance(): Promise<unknown> {
// 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)) {
throw new Error(`Instance for [${this._type.toString()}] is missing start() method`);
}
await this._startWithTimeout(instance, SERVICE_START_TIMEOUT);
}
return instance;
}
private async _startWithTimeout(instance: IStartable, timeout: number): Promise<void> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Timed out starting [${this._instanceType}]`));
}, timeout);
});
await Promise.race([instance.start(), timeoutPromise]);
}
public toString(): string {
return `DependencyProvider[${this._type}]`;
}
}

View File

@@ -0,0 +1,3 @@
export interface IConfigurationLoader {
load(name: string): Promise<unknown>;
}

View File

@@ -1,8 +1,8 @@
import type Type from './Type';
import type {Type} from './Type';
import type IDisposable from '../lang/IDisposable';
import type IDependencyProvider from './IDependencyProvider';
import type {IDependencyProvider} from './IDependencyProvider';
export default interface IDependencyManager {
export interface IDependencyManager {
defineDependencies(type: Type, dependencies: Type[]): IDisposable;
getDependencies(type: Type): Type[] | undefined;
addProvider(provider: IDependencyProvider): IDisposable;

View File

@@ -1,6 +1,6 @@
import Type from './Type';
import {Type} from './Type';
export default interface IDependencyProvider {
export interface IDependencyProvider {
canProvide(type: Type): boolean;
provide(type: Type): Promise<unknown>;
toString(): string;

10
src/di/IInject.ts Normal file
View File

@@ -0,0 +1,10 @@
import type IDisposable from '../lang/IDisposable';
import {Type} from './Type';
export interface IInject extends IDisposable {
scope(): Promise<IInject>;
defineInstance(type: string, instance: unknown): void;
instantiate<T = unknown>(type: string): Promise<T>;
start(): Promise<unknown[]>;
getAvailableTypes(): Promise<Type[]>;
}

View 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;
}

83
src/di/Inject.ts Normal file
View File

@@ -0,0 +1,83 @@
import type {IDependencyManager} from './IDependencyManager';
import type {IInject} from './IInject';
import DisposableList from '../lang/DisposableList';
import {InstanceProvider} from './InstanceProvider';
import {Type} from './Type';
export class Inject implements IInject {
private readonly _dependencyManager: IDependencyManager;
private readonly _scope: DisposableList;
private readonly _endedCallbacks: (() => void)[];
private _activeChildScope: Inject | null = null;
constructor(dependencyManager: IDependencyManager) {
this._dependencyManager = dependencyManager;
this._scope = new DisposableList();
this._endedCallbacks = [];
}
public async scope(): Promise<IInject> {
// Only one scope can be active at any given time
if (!this._activeChildScope) {
const scope = new Inject(this._dependencyManager);
this._activeChildScope = scope;
// Unregister scope when it ends
scope._endedCallbacks.push(() => {
if (this._activeChildScope === scope) {
this._activeChildScope = null;
}
});
return scope;
}
// Wait for the current scope to end
return new Promise<IInject>(resolve => {
this._activeChildScope!._endedCallbacks.push(() => {
resolve(this.scope());
});
});
}
defineInstance(type: string, instance: unknown): void {
const disposable = this._dependencyManager.addProvider(new InstanceProvider(new Type(type), instance));
this._scope.add(disposable);
}
async instantiate<T = unknown>(type: string): Promise<T> {
const theType = new Type(type);
const provider = await this._dependencyManager.resolveProvider(theType);
return (await provider.provide(theType)) as T;
}
async start(): Promise<unknown[]> {
const types = await this._dependencyManager.getEagerTypes();
const instances = await Promise.all(
types.map(async type => {
const provider = await this._dependencyManager.resolveProvider(type);
return provider.provide(type);
})
);
return instances;
}
async getAvailableTypes(): Promise<Type[]> {
return this._dependencyManager.getTypes();
}
dispose(): void {
this._scope.dispose();
// Notify all listeners
for (const callback of this._endedCallbacks) {
callback();
}
}
toString(): string {
return 'Inject';
}
}

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2025 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {Type} from './Type';
import type {IDependencyProvider} from './IDependencyProvider';
/**
* A dependency provider for pre-existing instances.
*/
export class InstanceProvider implements IDependencyProvider {
private readonly _type: Type;
private readonly _instance: unknown;
constructor(type: Type, instance: unknown) {
if (!(type instanceof Type)) {
throw new Error('Must provide a valid type');
}
if (instance === null || instance === undefined) {
throw new Error('Must provide a valid instance');
}
this._type = type;
this._instance = instance;
}
canProvide(type: Type): boolean {
if (!(type instanceof Type)) {
throw new Error('Must provide a valid type');
}
return type.equals(this._type);
}
async provide(type: Type): Promise<unknown> {
if (!(type instanceof Type)) {
throw new Error('Must provide a valid type');
}
return this._instance;
}
toString(): string {
return 'InstanceProvider';
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2025 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import type {IDependencyProvider} from './IDependencyProvider';
import {Type} from './Type';
import {NamedType} from './NamedType';
/**
* A dependency provider for integer constants.
* Uses NamedType where the name is parsed as an integer.
*/
export class IntegerConstantProvider implements IDependencyProvider {
public canProvide(type: Type): boolean {
if (!(type instanceof Type)) {
throw new Error('Must provide a valid type');
}
return type instanceof NamedType && type.getType() === 'di/IntegerConstantProvider';
}
public async provide(type: Type): Promise<number> {
if (!(type instanceof NamedType)) {
throw new Error('Must provide a NamedType');
}
return parseInt(type.getName(), 10);
}
public toString(): string {
return 'IntegerConstantProvider';
}
}

View 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);
}
}

37
src/di/Lifecycle.ts Normal file
View File

@@ -0,0 +1,37 @@
import assertUnreachable from '../lang/assertUnreachable';
export enum Lifecycle {
Singleton = 0,
Service = 1,
Instance = 2
}
export type LifecycleType = 'singleton' | 'service' | 'instance';
export class LifecycleMapping {
public static convertLifecycleToLifecycleType(lifecycle: Lifecycle): LifecycleType {
switch (lifecycle) {
case Lifecycle.Singleton:
return 'singleton';
case Lifecycle.Service:
return 'service';
case Lifecycle.Instance:
return 'instance';
default:
return assertUnreachable(lifecycle);
}
}
public static convertLifecycleTypeToLifecycle(lifecycleType: LifecycleType): Lifecycle {
switch (lifecycleType) {
case 'singleton':
return Lifecycle.Singleton;
case 'service':
return Lifecycle.Service;
case 'instance':
return Lifecycle.Instance;
default:
return assertUnreachable(lifecycleType);
}
}
}

View File

@@ -1,6 +1,6 @@
import Type from './Type';
import {Type} from './Type';
export default class NamedType extends Type {
export class NamedType extends Type {
private readonly _name: string;
constructor(name: string, type: string) {
@@ -12,15 +12,15 @@ export default class NamedType extends Type {
return this._name;
}
public override equals(other: Type) {
return super.equals(other) && other instanceof Type && this.getName() === (other as NamedType).getName();
public override equals(other: Type): boolean {
return super.equals(other) && other instanceof NamedType && this.getName() === other.getName();
}
public overridetoURN(): string {
public override toURN(): string {
return `urn:namedtype:${super.getType()}#${this._name}`;
}
public overridetoString(): string {
public override toString(): string {
return `${super.getType()}#${this._name}`;
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2025 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import type {IDependencyProvider} from './IDependencyProvider';
import {Type} from './Type';
import {NamedType} from './NamedType';
/**
* A dependency provider for string constants.
* Uses NamedType where the name is the string value.
*/
export class StringConstantProvider implements IDependencyProvider {
public canProvide(type: Type): boolean {
if (!(type instanceof Type)) {
throw new Error('Must provide a valid type');
}
return type instanceof NamedType && type.getType() === 'di/StringConstantProvider';
}
public async provide(type: Type): Promise<string> {
if (!(type instanceof NamedType)) {
throw new Error('Must provide a NamedType');
}
return type.getName();
}
public toString(): string {
return 'StringConstantProvider';
}
}

View File

@@ -1,4 +1,4 @@
export default class Type {
export class Type {
private _type: string;
constructor(type: string) {
@@ -10,7 +10,8 @@ export default 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() {

16
src/di/index.ts Normal file
View 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
View 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
View 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
View 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';
}
}

View File

@@ -0,0 +1,5 @@
export interface IConnection {
connect(): Promise<void>;
disconnect(): Promise<void>;
toString(): string;
}

View File

@@ -0,0 +1,4 @@
export interface IMessageQueue {
publish(topic: string, message: string): Promise<void>;
subscribe(topic: string, handler: (message: string) => void): Promise<void>;
}

View 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
View 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;
}
}

View 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}]]`;
}
}

10
src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
// 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 {default as assertUnreachable} from './lang/assertUnreachable';

View File

@@ -1,4 +1,4 @@
import IDisposable from './IDisposable';
import type IDisposable from './IDisposable';
export default class Disposable implements IDisposable {
private readonly _cleanup: () => void;

View File

@@ -1,4 +1,4 @@
import IDisposable from './IDisposable';
import type IDisposable from './IDisposable';
export default class DisposableList implements IDisposable {
private readonly _list: IDisposable[];
@@ -15,7 +15,7 @@ export default class DisposableList implements IDisposable {
while (this._list.length) {
const disposable = this._list.shift();
disposable.dispose();
disposable!.dispose();
}
}
}

53
src/lang/Events.ts Normal file
View 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);
}
}

View File

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

12
src/types/Units.ts Normal file
View File

@@ -0,0 +1,12 @@
export type Seconds = number;
export type Milliseconds = number;
export type Microseconds = number;
export type Nanoseconds = number;
export type Bytes = number;
export type Kilobytes = number;
export type Megabytes = number;
export type Gigabytes = number;
export type Terabytes = number;
export type Petabytes = number;
export type Exabytes = number;

View File

@@ -7,12 +7,18 @@
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist/types",
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
"noEmit": false,
// Best practices
"strict": true,
@@ -25,5 +31,7 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
},
"include": ["src/**/*"],
"exclude": ["node_modules/**/*", "test/**/*", "scripts/**/*"]
}