From 00fb21c55828dd1d4925e4b38c20295ce7e562d5 Mon Sep 17 00:00:00 2001 From: M1000fr Date: Sat, 10 Jan 2026 17:27:30 +0100 Subject: [PATCH] feat: init project --- .gitignore | 34 +++ .zed/settings.json | 22 ++ README.md | 10 +- biome.json | 68 +++++ bun.lock | 50 ++++ index.ts | 1 + package.json | 21 ++ src/container/Container.ts | 339 +++++++++++++++++++++++++ src/container/types.ts | 60 +++++ src/decorators/index.ts | 3 + src/decorators/inject.decorator.ts | 22 ++ src/decorators/injectable.decorator.ts | 16 ++ src/decorators/module.decorator.ts | 14 + src/errors/index.ts | 47 ++++ src/factory/AlveoApplication.ts | 36 +++ src/factory/AlveoFactory.ts | 38 +++ src/index.ts | 19 ++ src/lifecycle/index.ts | 15 ++ src/module/ModuleRef.ts | 37 +++ src/types.ts | 10 + test.ts | 69 +++++ tsconfig.json | 31 +++ 22 files changed, 957 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 .zed/settings.json create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/container/Container.ts create mode 100644 src/container/types.ts create mode 100644 src/decorators/index.ts create mode 100644 src/decorators/inject.decorator.ts create mode 100644 src/decorators/injectable.decorator.ts create mode 100644 src/decorators/module.decorator.ts create mode 100644 src/errors/index.ts create mode 100644 src/factory/AlveoApplication.ts create mode 100644 src/factory/AlveoFactory.ts create mode 100644 src/index.ts create mode 100644 src/lifecycle/index.ts create mode 100644 src/module/ModuleRef.ts create mode 100644 src/types.ts create mode 100644 test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..f393c61 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,22 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "formatter": { "language_server": { "name": "biome" } }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + }, + "lsp": { + "biome": { + "settings": { + "require_config_file": true + }, + "binary": { + "path": "node_modules/.bin/biome", + "arguments": ["lsp-proxy"] + } + } + } +} diff --git a/README.md b/README.md index 418f1b2..4d65b6f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# core +# Alveo + +Alveo is a lightweight example of DI (Dependency Injection) container for TypeScript and JavaScript projects, designed to be simple and easy to use. To install dependencies: @@ -6,10 +8,8 @@ To install dependencies: bun install ``` -To run: +To run test: ```bash -bun run index.ts +bun test.ts ``` - -This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..0a785b7 --- /dev/null +++ b/biome.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "formatWithErrors": true, + "indentStyle": "tab", + "indentWidth": 4, + "lineEnding": "lf", + "lineWidth": 80, + "attributePosition": "auto" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noStaticOnlyClass": "off", + "noBannedTypes": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedPrivateClassMembers": "error", + "noUnusedFunctionParameters": "off" + }, + "style": { + "useImportType": "off", + "useNodejsImportProtocol": "off", + "useTemplate": "off", + "noNonNullAssertion": "off", + "useLiteralEnumMembers": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noImplicitAnyLet": "off", + "noAssignInExpressions": "off", + "useIterableCallbackReturn": "off", + "noShadowRestrictedNames": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + }, + "parser": { + "unsafeParameterDecoratorsEnabled": true + } + }, + "overrides": [ + { + "includes": ["dist/**", "node_modules/**"], + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + } + ] +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..35c7053 --- /dev/null +++ b/bun.lock @@ -0,0 +1,50 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "core", + "dependencies": { + "reflect-metadata": "^0.2.2", + }, + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..3bd16e1 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..afaa58b --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "@alveojs/core", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "reflect-metadata": "^0.2.2" + }, + "scripts": { + "format": "biome format", + "lint": "biome lint", + "typecheck": "tsc --noEmit" + } +} diff --git a/src/container/Container.ts b/src/container/Container.ts new file mode 100644 index 0000000..9783a65 --- /dev/null +++ b/src/container/Container.ts @@ -0,0 +1,339 @@ +import "reflect-metadata"; +import { CircularDependencyError, ProviderNotFoundError } from "../errors"; +import { ContextualModuleRef, ModuleRef } from "../module/ModuleRef"; +import type { Constructor } from "../types"; +import { + type BaseProvider, + INJECT_METADATA_KEY, + INJECTABLE_METADATA_KEY, + MODULE_METADATA_KEY, + type ModuleOptions, + type Provider, + type ProviderToken, + type ResolvedProvider, +} from "./types"; + +interface ParamType { + name?: string; +} + +type LifecycleHook = "onModuleInit" | "onModuleDestroy"; +type LifecycleHookFn = () => Promise | void; +type LifecycleAware = Partial>; + +export class Container { + private readonly providers = new Map< + string, + Map + >(); + private readonly instances = new Map>(); + private readonly moduleOptions = new Map(); + private readonly resolving = new Set(); + private rootModuleName?: string; + + constructor(private readonly globalContext = "global") { + this.ensureScope(this.globalContext); + } + + public registerRootModule(moduleClass: Constructor): void { + this.rootModuleName = moduleClass.name; + this.registerModule(moduleClass); + } + + public getRootModuleName(): string | undefined { + return this.rootModuleName; + } + + public registerModule(moduleClass: Constructor): void { + const options: ModuleOptions | undefined = Reflect.getMetadata( + MODULE_METADATA_KEY, + moduleClass, + ); + if (!options) return; + + const moduleName = moduleClass.name; + if (this.moduleOptions.has(moduleName)) return; + + this.moduleOptions.set(moduleName, options); + this.ensureScope(moduleName); + + if (options.imports) { + for (const imported of options.imports) { + this.registerModule(imported); + } + } + + if (options.providers) { + for (const provider of options.providers) { + const normalized = this.normalizeProvider(provider, moduleName); + this.providers + .get(moduleName)! + .set(normalized.token, normalized); + } + } + + const moduleProvider = this.normalizeProvider(moduleClass, moduleName); + this.providers.get(moduleName)!.set(moduleClass, moduleProvider); + } + + public registerProvider(provider: Provider): void { + const normalized = this.normalizeProvider(provider, this.globalContext); + this.providers + .get(this.globalContext)! + .set(normalized.token, normalized); + } + + public async get( + token: ProviderToken, + moduleName?: string, + ): Promise { + return this.resolve(token, moduleName); + } + + public async resolveAll(): Promise { + for (const [scope] of this.providers) { + for (const token of this.providers.get(scope)!.keys()) { + await this.resolve( + token, + scope === this.globalContext ? undefined : scope, + ); + } + } + } + + public async callLifecycleHook( + hook: "onModuleInit" | "onModuleDestroy", + ): Promise { + for (const instances of this.instances.values()) { + for (const instance of instances.values()) { + if (!instance) continue; + const lifecycleInstance = instance as LifecycleAware; + const hookFn = lifecycleInstance[hook]; + if (typeof hookFn === "function") { + await hookFn.call(lifecycleInstance); + } + } + } + } + + private async resolve( + token: ProviderToken, + moduleName?: string, + ): Promise { + const context = moduleName ?? this.globalContext; + + if (token === ModuleRef) { + return new ContextualModuleRef(this, context) as T; + } + + let provider = this.findProvider(token, context); + + if ( + !provider && + context === this.globalContext && + this.rootModuleName + ) { + provider = this.findProvider(token, this.rootModuleName); + } + + if (!provider) { + throw new ProviderNotFoundError( + typeof token === "function" ? token.name : String(token), + context, + ); + } + + if (provider.scope === "singleton") { + const existing = this.instances + .get(provider.moduleName ?? context) + ?.get(provider.token); + if (existing !== undefined) { + return existing as T; + } + } + + if (this.resolving.has(token)) { + const stack = [...this.resolving].map((item) => + typeof item === "function" ? item.name : String(item), + ); + stack.push( + typeof token === "function" ? token.name : String(token), + ); + throw new CircularDependencyError(stack); + } + + this.resolving.add(token); + + try { + let instance: T; + + if (provider.useValue !== undefined) { + instance = provider.useValue as T; + } else if (provider.useFactory) { + const dependencies = await Promise.all( + (provider.inject ?? []).map((dep) => + this.resolve(dep, provider.moduleName ?? context), + ), + ); + instance = (await provider.useFactory(...dependencies)) as T; + } else if (provider.useExisting) { + instance = await this.resolve( + provider.useExisting as ProviderToken, + provider.moduleName ?? context, + ); + } else if (provider.useClass) { + const targetClass = provider.useClass; + const paramTypes: ParamType[] = + Reflect.getMetadata("design:paramtypes", targetClass) || []; + const injectTokens: ProviderToken[] = + Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) || []; + + this.debug(`[Container] Instantiating ${targetClass.name}`); + this.debug( + "[Container] ParamTypes:", + paramTypes.map((p) => p?.name || String(p)), + ); + this.debug( + "[Container] InjectTokens:", + injectTokens.map((token) => + token ? String(token) : "undefined", + ), + ); + + const dependencies = await Promise.all( + paramTypes.map(async (paramType, index) => { + const depToken = + injectTokens[index] || (paramType as ProviderToken); + this.debug( + `[Container] Resolving dependency ${index}: ${String(depToken)}`, + ); + return this.resolve( + depToken as ProviderToken, + provider.moduleName ?? context, + ); + }), + ); + + instance = new targetClass(...dependencies) as T; + } else { + throw new Error( + `Invalid provider configuration for token ${String(token)}`, + ); + } + + if (provider.scope === "singleton") { + const scopeName = provider.moduleName ?? context; + this.ensureScope(scopeName); + this.instances.get(scopeName)!.set(provider.token, instance); + } + + return instance; + } finally { + this.resolving.delete(token); + } + } + + private findProvider( + token: ProviderToken, + context: string, + ): ResolvedProvider | undefined { + const moduleProviders = this.providers.get(context); + if (moduleProviders?.has(token)) { + return moduleProviders.get(token); + } + + const options = this.moduleOptions.get(context); + if (options?.imports) { + for (const imported of options.imports) { + const importedProviders = this.providers.get(imported.name); + const importedOptions = this.moduleOptions.get(imported.name); + if ( + importedProviders?.has(token) && + importedOptions?.exports?.includes(token) + ) { + return importedProviders.get(token); + } + if (token === imported) { + return importedProviders?.get(token); + } + } + } + + if (context !== this.globalContext) { + const globalProviders = this.providers.get(this.globalContext); + if (globalProviders?.has(token)) { + return globalProviders.get(token); + } + } + + if (context === this.globalContext && this.rootModuleName) { + const rootProviders = this.providers.get(this.rootModuleName); + if (rootProviders?.has(token)) { + return rootProviders.get(token); + } + } + + return undefined; + } + + private normalizeProvider( + provider: Provider, + moduleName?: string, + ): ResolvedProvider { + if (typeof provider === "function") { + const metadata = Reflect.getMetadata( + INJECTABLE_METADATA_KEY, + provider, + ); + return { + token: provider, + scope: metadata?.scope ?? "singleton", + useClass: provider, + moduleName, + }; + } + + const base = { + token: provider.provide, + scope: provider.scope ?? "singleton", + moduleName, + }; + + if ("useClass" in provider) { + return { ...base, useClass: provider.useClass }; + } + if ("useValue" in provider) { + return { ...base, useValue: provider.useValue }; + } + if ("useFactory" in provider) { + return { + ...base, + useFactory: provider.useFactory, + inject: provider.inject, + }; + } + if ("useExisting" in provider) { + return { ...base, useExisting: provider.useExisting }; + } + + const exhaustiveCheck: never = provider; + throw new Error( + `Invalid provider definition for token ${String((exhaustiveCheck as BaseProvider).provide)}`, + ); + } + + private ensureScope(name: string): void { + if (!this.providers.has(name)) { + this.providers.set(name, new Map()); + } + if (!this.instances.has(name)) { + this.instances.set(name, new Map()); + } + } + + private debug(...args: unknown[]): void { + if (process.env.ALVEO_CONTAINER_DEBUG === "true") { + console.debug(...args); + } + } +} diff --git a/src/container/types.ts b/src/container/types.ts new file mode 100644 index 0000000..3e927f9 --- /dev/null +++ b/src/container/types.ts @@ -0,0 +1,60 @@ +import type { Constructor, Type } from "../types"; + +export type ProviderScope = "singleton" | "transient"; + +export type ProviderToken = Type | string | symbol; + +export interface BaseProvider { + provide: ProviderToken; + scope?: ProviderScope; +} + +export interface ClassProvider extends BaseProvider { + useClass: Constructor; +} + +export interface ValueProvider extends BaseProvider { + useValue: T; +} + +export interface FactoryProvider extends BaseProvider { + useFactory: (...args: unknown[]) => T | Promise; + inject?: ProviderToken[]; +} + +export interface ExistingProvider extends BaseProvider { + useExisting: ProviderToken; +} + +export type Provider = + | Constructor + | ClassProvider + | ValueProvider + | FactoryProvider + | ExistingProvider; + +export interface ResolvedProvider { + token: ProviderToken; + scope: ProviderScope; + useClass?: Constructor; + useValue?: T; + useFactory?: (...args: unknown[]) => T | Promise; + useExisting?: ProviderToken; + inject?: ProviderToken[]; + moduleName?: string; +} + +export interface InjectableOptions { + scope?: ProviderScope; +} + +export interface ModuleOptions { + imports?: Constructor[]; + providers?: Provider[]; + exports?: ProviderToken[]; +} + +export const INJECTABLE_METADATA_KEY = "alveo:injectable"; +export const INJECT_METADATA_KEY = "alveo:inject"; +export const MODULE_METADATA_KEY = "alveo:module"; +export const PARAMTYPES_METADATA_KEY = "design:paramtypes"; diff --git a/src/decorators/index.ts b/src/decorators/index.ts new file mode 100644 index 0000000..88a2782 --- /dev/null +++ b/src/decorators/index.ts @@ -0,0 +1,3 @@ +export * from "./inject.decorator"; +export * from "./injectable.decorator"; +export * from "./module.decorator"; diff --git a/src/decorators/inject.decorator.ts b/src/decorators/inject.decorator.ts new file mode 100644 index 0000000..fc82f64 --- /dev/null +++ b/src/decorators/inject.decorator.ts @@ -0,0 +1,22 @@ +import "reflect-metadata"; +import { INJECT_METADATA_KEY, type ProviderToken } from "../container/types"; + +/** + * Decorator that explicitly specifies a token for dependency injection. + * Useful when injecting interfaces (via symbols or strings) or when multiple + * providers are available for the same type. + * + * @param token The token to inject + */ +export function Inject(token: ProviderToken): ParameterDecorator { + return ( + target: object, + _propertyKey: string | symbol | undefined, + parameterIndex: number, + ) => { + const existingInjections = + Reflect.getMetadata(INJECT_METADATA_KEY, target) || []; + existingInjections[parameterIndex] = token; + Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target); + }; +} diff --git a/src/decorators/injectable.decorator.ts b/src/decorators/injectable.decorator.ts new file mode 100644 index 0000000..fa2c9ad --- /dev/null +++ b/src/decorators/injectable.decorator.ts @@ -0,0 +1,16 @@ +import "reflect-metadata"; +import { + INJECTABLE_METADATA_KEY, + type InjectableOptions, +} from "../container/types"; + +/** + * Decorator that marks a class as available to be provided and injected as a dependency. + * + * @param options Options for the injectable (e.g., scope) + */ +export function Injectable(options?: InjectableOptions): ClassDecorator { + return (target: object) => { + Reflect.defineMetadata(INJECTABLE_METADATA_KEY, options || {}, target); + }; +} diff --git a/src/decorators/module.decorator.ts b/src/decorators/module.decorator.ts new file mode 100644 index 0000000..7b7185d --- /dev/null +++ b/src/decorators/module.decorator.ts @@ -0,0 +1,14 @@ +import "reflect-metadata"; +import { MODULE_METADATA_KEY, type ModuleOptions } from "../container/types"; + +/** + * Decorator that marks a class as an Alveo module. + * Modules are used to organize the application structure and define providers, imports, and exports. + * + * @param options Module configuration options + */ +export function Module(options: ModuleOptions): ClassDecorator { + return (target: object) => { + Reflect.defineMetadata(MODULE_METADATA_KEY, options, target); + }; +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..f3daeac --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,47 @@ +export class AlveoError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class ProviderNotFoundError extends AlveoError { + constructor(token: string, context?: string) { + const contextMsg = context ? ` in the "${context}" context` : ""; + super( + `Alveo can't resolve dependencies of the ${token}${contextMsg}. Please make sure that it is available in the current module scope.`, + ); + } +} + +export class CircularDependencyError extends AlveoError { + constructor(stack: string[]) { + super(`Circular dependency detected: ${stack.join(" -> ")}`); + } +} + +export class InvalidModuleError extends AlveoError { + constructor(target: string) { + super( + `The class "${target}" is not a valid Alveo module. Did you forget the @Module() decorator?`, + ); + } +} + +export class InvalidProviderError extends AlveoError { + constructor(provider: unknown) { + super(`Invalid provider definition: ${JSON.stringify(provider)}`); + } +} + +export class LifecycleError extends AlveoError { + constructor(hook: string, error: Error) { + super(`Error during lifecycle hook "${hook}": ${error.message}`); + } +} + +export class BootstrapError extends AlveoError { + constructor(message: string) { + super(`Application bootstrap failed: ${message}`); + } +} diff --git a/src/factory/AlveoApplication.ts b/src/factory/AlveoApplication.ts new file mode 100644 index 0000000..f38d42e --- /dev/null +++ b/src/factory/AlveoApplication.ts @@ -0,0 +1,36 @@ +import type { Container } from "../container/Container"; +import type { ProviderToken } from "../container/types"; + +/** + * AlveoApplication is the main class representing an Alveo application instance. + * It provides methods to interact with the dependency injection container and manage the application lifecycle. + */ +export class AlveoApplication { + constructor(private readonly container: Container) {} + + /** + * Retrieves an instance of a provider from the application container. + * + * @param token The provider token (Class, String, or Symbol) + * @returns A promise resolving to the instance of the provider + */ + public async get(token: ProviderToken): Promise { + return this.container.get(token); + } + + /** + * Initializes the application by calling onModuleInit hooks on all providers. + * This is called automatically by AlveoFactory.create(). + */ + public async init(): Promise { + await this.container.callLifecycleHook("onModuleInit"); + return this; + } + + /** + * Gracefully shuts down the application by calling onModuleDestroy hooks. + */ + public async close(): Promise { + await this.container.callLifecycleHook("onModuleDestroy"); + } +} diff --git a/src/factory/AlveoFactory.ts b/src/factory/AlveoFactory.ts new file mode 100644 index 0000000..33d6d0a --- /dev/null +++ b/src/factory/AlveoFactory.ts @@ -0,0 +1,38 @@ +import { Container } from "../container/Container"; +import type { Constructor } from "../types"; +import { AlveoApplication } from "./AlveoApplication"; + +/** + * AlveoFactory is the entry point for creating Alveo application instances. + * It handles the creation of the dependency injection container and module registration. + */ +export class AlveoFactoryStatic { + /** + * Creates an instance of an Alveo application. + * + * @param rootModule The root module of the application. + * @returns A promise that resolves to an AlveoApplication instance. + */ + public async create(rootModule: Constructor): Promise { + const container = new Container(); + + // 1. Register the module tree starting from the root module + container.registerRootModule(rootModule); + + // 2. Resolve all providers (this builds the graph and instantiates singletons) + await container.resolveAll(); + + // 3. Wrap in the application instance + const app = new AlveoApplication(container); + + // 4. Initialize lifecycle hooks + await app.init(); + + return app; + } +} + +/** + * Static instance of AlveoFactory to be used by consumers. + */ +export const AlveoFactory = new AlveoFactoryStatic(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c5f6cc5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +export { Container } from "./container/Container"; +export type { + ClassProvider, + ExistingProvider, + FactoryProvider, + InjectableOptions, + ModuleOptions, + Provider, + ProviderScope as Scope, + ProviderToken, + ValueProvider, +} from "./container/types"; +export * from "./decorators"; +export * from "./errors"; +export { AlveoApplication } from "./factory/AlveoApplication"; +export { AlveoFactory } from "./factory/AlveoFactory"; +export * from "./lifecycle"; +export { ModuleRef } from "./module/ModuleRef"; +export type { Abstract, Constructor, Type } from "./types"; diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts new file mode 100644 index 0000000..9f3e9e3 --- /dev/null +++ b/src/lifecycle/index.ts @@ -0,0 +1,15 @@ +/** + * Interface defining a lifecycle hook that is called once the module has been initialized. + * All dependencies have been resolved and instantiated at this point. + */ +export interface OnModuleInit { + onModuleInit(): void | Promise; +} + +/** + * Interface defining a lifecycle hook that is called when the application is shutting down. + * Useful for cleanup logic like closing database connections or stopping intervals. + */ +export interface OnModuleDestroy { + onModuleDestroy(): void | Promise; +} diff --git a/src/module/ModuleRef.ts b/src/module/ModuleRef.ts new file mode 100644 index 0000000..5c9eaf7 --- /dev/null +++ b/src/module/ModuleRef.ts @@ -0,0 +1,37 @@ +import type { Container } from "../container/Container"; +import type { ProviderToken } from "../container/types"; + +/** + * ModuleRef is an abstraction that allows for dynamic resolution of providers. + * It is typically injected into services or controllers when dynamic lookup is needed. + */ +export abstract class ModuleRef { + constructor(protected readonly container: Container) {} + + /** + * Retrieves an instance of a provider based on its token. + * + * @param token The token of the provider to retrieve. + * @returns A promise that resolves to the provider instance. + */ + public abstract get(token: ProviderToken): Promise; +} + +/** + * A contextual implementation of ModuleRef that resolves providers within a specific module's scope. + */ +export class ContextualModuleRef extends ModuleRef { + constructor( + container: Container, + private readonly moduleName: string, + ) { + super(container); + } + + /** + * Resolves the token within the context of the module this Ref belongs to. + */ + public override async get(token: ProviderToken): Promise { + return this.container.get(token, this.moduleName); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0cfbb73 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +// biome-ignore lint/suspicious/noExplicitAny: Required for constructor variance compatibility +export type ConstructorArgs = any[]; + +export type Constructor = new (...args: ConstructorArgs) => T; + +export type Abstract = abstract new ( + ...args: ConstructorArgs +) => T; + +export type Type = Constructor | Abstract; diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..1b180c8 --- /dev/null +++ b/test.ts @@ -0,0 +1,69 @@ +import "reflect-metadata"; +import { + AlveoFactory, + Inject, + Injectable, + Module, + ModuleRef, + type OnModuleInit, +} from "./index"; + +const LOGGER_TOKEN = Symbol("LOGGER"); + +@Injectable() +class LoggerService { + log(message: string) { + console.log(`[Logger]: ${message}`); + } +} + +@Injectable() +class DatabaseService implements OnModuleInit { + constructor( + @Inject(LOGGER_TOKEN) private readonly logger: LoggerService, + @Inject(ModuleRef) private readonly moduleRef: ModuleRef, + ) {} + + async onModuleInit() { + this.logger.log("DatabaseService initialized."); + const self = await this.moduleRef.get(DatabaseService); + this.logger.log(`ModuleRef self-check: ${self === this}`); + } + + query() { + return "data"; + } +} + +@Injectable() +export class TestService { + constructor(private readonly logger: LoggerService) { + this.logger.log("TestService initialized."); + } +} + +@Module({ + providers: [ + DatabaseService, + LoggerService, + { provide: LOGGER_TOKEN, useExisting: LoggerService }, + ], + exports: [DatabaseService, LoggerService], +}) +class DatabaseModule {} + +@Module({ + imports: [DatabaseModule], + providers: [TestService], +}) +class AppModule {} + +async function bootstrap() { + console.log("Starting AlveoJS test..."); + const app = await AlveoFactory.create(AppModule); + const db = await app.get(DatabaseService); + console.log(`Query result: ${db.query()}`); + await app.close(); +} + +bootstrap().catch(console.error); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f63f94e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}