diff --git a/.gitignore b/.gitignore index fbf9e2c..2d0c064 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -build/ +dist/ node_modules/ coverage/ diff --git a/package-lock.json b/package-lock.json index 2a98cf2..601f053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "@stevanfreeborn/netdi", "version": "0.0.0", "license": "MIT", + "dependencies": { + "reflect-metadata": "^0.2.2" + }, "devDependencies": { + "@swc/core": "^1.11.24", "@types/node": "^22.15.17", "@vitest/coverage-istanbul": "^3.1.3", "@vitest/ui": "^3.1.3", @@ -17,6 +21,7 @@ "prettier": "^3.5.3", "typescript": "^5.8.3", "typescript-eslint": "^8.32.0", + "unplugin-swc": "^1.5.2", "vitest": "^3.1.3" } }, @@ -1095,6 +1100,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.40.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", @@ -1375,6 +1410,232 @@ "win32" ] }, + "node_modules/@swc/core": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.24.tgz", + "integrity": "sha512-MaQEIpfcEMzx3VWWopbofKJvaraqmL6HbLlw2bFZ7qYqYw3rkhM0cQVEgyzbHtTWwCwPMFZSC2DUbhlZgrMfLg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.21" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.11.24", + "@swc/core-darwin-x64": "1.11.24", + "@swc/core-linux-arm-gnueabihf": "1.11.24", + "@swc/core-linux-arm64-gnu": "1.11.24", + "@swc/core-linux-arm64-musl": "1.11.24", + "@swc/core-linux-x64-gnu": "1.11.24", + "@swc/core-linux-x64-musl": "1.11.24", + "@swc/core-win32-arm64-msvc": "1.11.24", + "@swc/core-win32-ia32-msvc": "1.11.24", + "@swc/core-win32-x64-msvc": "1.11.24" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.24.tgz", + "integrity": "sha512-dhtVj0PC1APOF4fl5qT2neGjRLgHAAYfiVP8poJelhzhB/318bO+QCFWAiimcDoyMgpCXOhTp757gnoJJrheWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.24.tgz", + "integrity": "sha512-H/3cPs8uxcj2Fe3SoLlofN5JG6Ny5bl8DuZ6Yc2wr7gQFBmyBkbZEz+sPVgsID7IXuz7vTP95kMm1VL74SO5AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.24.tgz", + "integrity": "sha512-PHJgWEpCsLo/NGj+A2lXZ2mgGjsr96ULNW3+T3Bj2KTc8XtMUkE8tmY2Da20ItZOvPNC/69KroU7edyo1Flfbw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.24.tgz", + "integrity": "sha512-C2FJb08+n5SD4CYWCTZx1uR88BN41ZieoHvI8A55hfVf2woT8+6ZiBzt74qW2g+ntZ535Jts5VwXAKdu41HpBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.24.tgz", + "integrity": "sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.24.tgz", + "integrity": "sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.24.tgz", + "integrity": "sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.24.tgz", + "integrity": "sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.24.tgz", + "integrity": "sha512-9pKLIisE/Hh2vJhGIPvSoTK4uBSPxNVyXHmOrtdDot4E1FUUI74Vi8tFdlwNbaj8/vusVnb8xPXsxF1uB0VgiQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.24.tgz", + "integrity": "sha512-sybnXtOsdB+XvzVFlBVGgRHLqp3yRpHK7CrmpuDKszhj/QhmsaZzY/GHSeALlMtLup13M0gqbcQvsTNlAHTg3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz", + "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3391,6 +3652,16 @@ "node": ">= 0.8.0" } }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4014,6 +4285,12 @@ "node": ">= 0.8" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4731,6 +5008,35 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-swc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.2.tgz", + "integrity": "sha512-bf8DJO8lD1wpnwFglQpVH2XEaFfVsSU5C7yFyLwGT1gxskPtejlDeuttKxjtmHTSqrDsQrK0FCFdhw3Ny+K7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "load-tsconfig": "^0.2.5", + "unplugin": "^1.11.0" + }, + "peerDependencies": { + "@swc/core": "^1.2.108" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -4951,6 +5257,13 @@ } } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 1b8cd5b..bfd5025 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "license": "MIT", "type": "module", "devDependencies": { + "@swc/core": "^1.11.24", "@types/node": "^22.15.17", "@vitest/coverage-istanbul": "^3.1.3", "@vitest/ui": "^3.1.3", @@ -55,6 +56,10 @@ "prettier": "^3.5.3", "typescript": "^5.8.3", "typescript-eslint": "^8.32.0", + "unplugin-swc": "^1.5.2", "vitest": "^3.1.3" + }, + "dependencies": { + "reflect-metadata": "^0.2.2" } } diff --git a/src/decorators.ts b/src/decorators.ts new file mode 100644 index 0000000..ef442f3 --- /dev/null +++ b/src/decorators.ts @@ -0,0 +1,15 @@ +import type { ServiceIdentifier } from './types.js'; + +export function inject(serviceType: ServiceIdentifier): ParameterDecorator { + // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types + return (target: Object, _: string | symbol | undefined, parameterIndex: number) => { + Reflect.defineMetadata('di:paramtypes', serviceType, target, parameterIndex.toString()); + }; +} + +export function injectable(): ClassDecorator { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (target: Function) => { + Reflect.defineMetadata('di:injectable', true, target); + }; +} diff --git a/src/index.ts b/src/index.ts index 2392700..5656eb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,5 @@ -console.log('netdi'); +import "reflect-metadata"; + +export * from './serviceProvider.js'; +export * from './types.js'; +export * from './decorators.js'; diff --git a/src/serviceProvider.ts b/src/serviceProvider.ts new file mode 100644 index 0000000..e8b6a10 --- /dev/null +++ b/src/serviceProvider.ts @@ -0,0 +1,106 @@ +import type { + IServiceProvider, + IServiceScope, + ServiceDescriptor, + ServiceIdentifier, + Constructor, +} from './types.js'; + +export class ServiceScope implements IServiceScope { + public readonly serviceProvider: IServiceProvider; + + constructor(serviceProvider: IServiceProvider) { + this.serviceProvider = serviceProvider; + } + + dispose(): void { + this.serviceProvider.dispose(); + } +} + +export class ServiceProvider implements IServiceProvider { + private descriptors: Map, ServiceDescriptor>; + private singletonInstances: Map, unknown> = new Map(); + private scopedInstances: Map, unknown> = new Map(); + + constructor( + descriptors: Map, ServiceDescriptor>, + parent?: ServiceProvider, + ) { + this.descriptors = descriptors; + + if (parent) { + parent.singletonInstances.forEach((value, key) => { + this.singletonInstances.set(key, value); + }); + } + + for (const descriptor of descriptors.values()) { + if (descriptor.lifetime === 'singleton') { + this.resolveService(descriptor); + } + } + } + + getService(serviceType: ServiceIdentifier): T { + const descriptor = this.descriptors.get(serviceType); + + if (!descriptor) { + throw new Error(`Service of type ${serviceType.toString()} is not registered.`); + } + + return this.resolveService(descriptor as ServiceDescriptor); + } + + createScope(): IServiceScope { + const scopedProvider = new ServiceProvider(this.descriptors, this); + return new ServiceScope(scopedProvider); + } + + dispose(): void { + this.scopedInstances.clear(); + } + + private resolveService(descriptor: ServiceDescriptor): T { + const { serviceType, implementationType, lifetime, factory } = descriptor; + + switch (lifetime) { + case 'singleton': { + if (this.singletonInstances.has(serviceType)) { + return this.singletonInstances.get(serviceType) as T; + } + const singletonInstance = factory ? factory(this) : this.createInstance(implementationType); + this.singletonInstances.set(serviceType, singletonInstance); + return singletonInstance; + } + case 'scoped': { + if (this.scopedInstances.has(serviceType)) { + return this.scopedInstances.get(serviceType) as T; + } + const scopedInstance = factory ? factory(this) : this.createInstance(implementationType); + this.scopedInstances.set(serviceType, scopedInstance); + return scopedInstance; + } + case 'transient': + return factory ? factory(this) : this.createInstance(implementationType); + default: + throw new Error(`Unknown service lifetime: ${lifetime}`); + } + } + + private createInstance(ctor: Constructor): T { + const paramTypes = Reflect.getMetadata('design:paramtypes', ctor) ?? []; + + const params = paramTypes.map((_: unknown, index: number) => { + const serviceType = Reflect.getMetadata( + 'di:paramtypes', + ctor, + index.toString(), + ) as ServiceIdentifier; + + return this.getService(serviceType); + }); + + return new ctor(...params); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e25d132 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,40 @@ +export type ServiceLifetime = 'singleton' | 'scoped' | 'transient'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new (...args: any[]) => T; + +export type ServiceIdentifier = symbol & { __brand: T }; + +export type ServiceFactory = (provider: IServiceProvider) => T; + +export type ServiceDescriptor = { + serviceType: ServiceIdentifier; + implementationType: Constructor; + lifetime: ServiceLifetime; + factory?: ServiceFactory; +}; + +export function createServiceIdentifier(): ServiceIdentifier { + return Symbol() as ServiceIdentifier; +} + +export interface IServiceCollection { + addSingleton(serviceType: ServiceIdentifier, implementationType: Constructor): IServiceCollection; + addSingleton(serviceType: ServiceIdentifier, factory: ServiceFactory): IServiceCollection; + addScoped(serviceType: ServiceIdentifier, implementationType: Constructor): IServiceCollection; + addScoped(serviceType: ServiceIdentifier, factory: ServiceFactory): IServiceCollection; + addTransient(serviceType: ServiceIdentifier, implementationType: Constructor): IServiceCollection; + addTransient(serviceType: ServiceIdentifier, factory: ServiceFactory): IServiceCollection; + buildServiceProvider(): IServiceProvider; +} + +export interface IServiceProvider { + getService(serviceType: ServiceIdentifier): T; + createScope(): IServiceScope; + dispose(): void; +} + +export interface IServiceScope { + serviceProvider: IServiceProvider; + dispose(): void; +} diff --git a/tests/index.spec.ts b/tests/index.spec.ts deleted file mode 100644 index 89b96c0..0000000 --- a/tests/index.spec.ts +++ /dev/null @@ -1 +0,0 @@ -console.log('netdi tests'); diff --git a/tests/serviceProvider.spec.ts b/tests/serviceProvider.spec.ts new file mode 100644 index 0000000..ecca934 --- /dev/null +++ b/tests/serviceProvider.spec.ts @@ -0,0 +1,509 @@ +import { describe, expect, it, test } from 'vitest'; +import { injectable, ServiceProvider } from '../src'; +import { createServiceIdentifier, ServiceLifetime } from '../src/types'; +import { randomUUID } from 'crypto'; +import { inject } from '../src'; + +describe('ServiceProvider', () => { + it('it should throw an error when service is not registered', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider(new Map()); + + expect(() => { + serviceProvider.getService(serviceIdentifier); + }).toThrowError(); + }); + + it('it should resolve singleton service', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'singleton', + }, + ], + ]), + ); + + const service1 = serviceProvider.getService(serviceIdentifier); + const service2 = serviceProvider.getService(serviceIdentifier); + + expect(service1).toBe(service2); + }); + + it('it should resolve singleton service using factory', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'singleton', + factory: () => new Object(), + }, + ], + ]), + ); + + const service1 = serviceProvider.getService(serviceIdentifier); + const service2 = serviceProvider.getService(serviceIdentifier); + + expect(service1).toBe(service2); + }); + + it('it should resolve scoped service', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'scoped', + }, + ], + ]), + ); + + const scope1 = serviceProvider.createScope(); + const scope2 = serviceProvider.createScope(); + + const service1 = scope1.serviceProvider.getService(serviceIdentifier); + const service2 = scope2.serviceProvider.getService(serviceIdentifier); + + expect(service1).not.toBe(service2); + }); + + it('it should resolve scoped service using factory', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'scoped', + factory: () => new Object(), + }, + ], + ]), + ); + + const scope1 = serviceProvider.createScope(); + const scope2 = serviceProvider.createScope(); + + const service1 = scope1.serviceProvider.getService(serviceIdentifier); + const service2 = scope2.serviceProvider.getService(serviceIdentifier); + + expect(service1).not.toBe(service2); + }); + + it('it should resolve transient service', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'transient', + }, + ], + ]), + ); + + const service1 = serviceProvider.getService(serviceIdentifier); + const service2 = serviceProvider.getService(serviceIdentifier); + + expect(service1).not.toBe(service2); + }); + + it('it should resolve transient service using factory', () => { + type IService = object; + const serviceIdentifier = createServiceIdentifier(); + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'transient', + factory: () => new Object(), + }, + ], + ]), + ); + + const service1 = serviceProvider.getService(serviceIdentifier); + const service2 = serviceProvider.getService(serviceIdentifier); + + expect(service1).not.toBe(service2); + }); + + it('it should resolve same singleton instance across scopes', () => { + type IService = { + id: string; + }; + + class Service implements IService { + id: string; + + constructor() { + this.id = randomUUID(); + } + } + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Service, + lifetime: 'singleton', + }, + ], + ]), + ); + + const scope1 = serviceProvider.createScope(); + const scope2 = serviceProvider.createScope(); + + const service1 = scope1.serviceProvider.getService(serviceIdentifier); + const service2 = scope2.serviceProvider.getService(serviceIdentifier); + + expect(service1.id).toBe(service2.id); + }); + + it('it should resolve solve injected dependencies', () => { + interface IUserRepository { + getUser(): string; + } + + @injectable() + class UserRepository implements IUserRepository { + getUser(): string { + return 'John Doe'; + } + } + + const userRepositoryIdentifier = createServiceIdentifier(); + + interface IUserService { + getUser(): string; + } + + class UserService implements IUserService { + private userRepository: IUserRepository; + + constructor(@inject(userRepositoryIdentifier) userRepository: IUserRepository) { + this.userRepository = userRepository; + } + + getUser(): string { + return this.userRepository.getUser(); + } + } + + const userServiceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + userRepositoryIdentifier, + { + serviceType: userRepositoryIdentifier, + implementationType: UserRepository, + lifetime: 'singleton', + }, + ], + [ + userServiceIdentifier, + { + serviceType: userServiceIdentifier, + implementationType: UserService, + lifetime: 'singleton', + }, + ], + ]), + ); + + const userService = serviceProvider.getService(userServiceIdentifier); + + const user = userService.getUser(); + + expect(user).toBe('John Doe'); + }); + + test('it should throw an error when service has invalid lifetime', () => { + type IService = object; + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Object, + lifetime: 'invalid' as ServiceLifetime, + }, + ], + ]), + ); + + expect(() => { + serviceProvider.getService(serviceIdentifier); + }).toThrowError(); + }); + + test('it should resolve same scoped instance in the same scope', () => { + type IService = { + id: string; + }; + + class Service implements IService { + id: string; + + constructor() { + this.id = randomUUID(); + } + } + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Service, + lifetime: 'scoped', + }, + ], + ]), + ); + + const scope = serviceProvider.createScope(); + + const service1 = scope.serviceProvider.getService(serviceIdentifier); + const service2 = scope.serviceProvider.getService(serviceIdentifier); + + expect(service1).toBe(service2); + }); + + test('it should clear the scoped instances on dispose from provider', () => { + type IService = { + id: string; + }; + + class Service implements IService { + id: string; + + constructor() { + this.id = randomUUID(); + } + } + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Service, + lifetime: 'scoped', + }, + ], + ]), + ); + + const initialService = serviceProvider.getService(serviceIdentifier); + + serviceProvider.dispose(); + + const newService = serviceProvider.getService(serviceIdentifier); + + expect(initialService).not.toBe(newService); + }); + + test('it should clear the scoped instances on dispose from a scope', () => { + type IService = { + id: string; + }; + + class Service implements IService { + id: string; + + constructor() { + this.id = randomUUID(); + } + } + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Service, + lifetime: 'scoped', + }, + ], + ]), + ); + + const scope = serviceProvider.createScope(); + + const initialService = scope.serviceProvider.getService(serviceIdentifier); + + scope.dispose(); + + const newService = scope.serviceProvider.getService(serviceIdentifier); + + expect(initialService).not.toBe(newService); + }); + + test('it should not clear the scoped instances on dispose from a scope when the provider is disposed', () => { + type IService = { + id: string; + }; + + class Service implements IService { + id: string; + + constructor() { + this.id = randomUUID(); + } + } + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Service, + lifetime: 'scoped', + }, + ], + ]), + ); + + const scope = serviceProvider.createScope(); + + const initialService = scope.serviceProvider.getService(serviceIdentifier); + + serviceProvider.dispose(); + + const newService = scope.serviceProvider.getService(serviceIdentifier); + + expect(initialService).toBe(newService); + }); + + test("it should not clear the scoped instances on dispose from another scope when one scope is disposed", () => { + type IService = { + id: string; + }; + + class Service implements IService { + id: string; + + constructor() { + this.id = randomUUID(); + } + } + + const serviceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + serviceIdentifier, + { + serviceType: serviceIdentifier, + implementationType: Service, + lifetime: 'scoped', + }, + ], + ]), + ); + + const scope1 = serviceProvider.createScope(); + const scope2 = serviceProvider.createScope(); + + const initialService1 = scope1.serviceProvider.getService(serviceIdentifier); + const initialService2 = scope2.serviceProvider.getService(serviceIdentifier); + + scope1.dispose(); + + const newService1 = scope1.serviceProvider.getService(serviceIdentifier); + const newService2 = scope2.serviceProvider.getService(serviceIdentifier); + + expect(initialService1).not.toBe(newService1); + expect(initialService2).toBe(newService2); + }); + + test('it should throw error when dependency is not registered', () => { + interface IUserRepository { + getUser(): string; + } + + class UserService { + private userRepository: IUserRepository; + + constructor(@inject('IUserRepository') userRepository: IUserRepository) { + this.userRepository = userRepository; + } + + getUser(): string { + return this.userRepository.getUser(); + } + } + + const userServiceIdentifier = createServiceIdentifier(); + + const serviceProvider = new ServiceProvider( + new Map([ + [ + userServiceIdentifier, + { + serviceType: userServiceIdentifier, + implementationType: UserService, + lifetime: 'scoped', + }, + ], + ]), + ); + + expect(() => { + serviceProvider.getService(userServiceIdentifier); + }).toThrowError(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fc8415f..f059f9c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import swc from 'unplugin-swc'; export default defineConfig({ test: { @@ -10,4 +11,5 @@ export default defineConfig({ exclude: ['**/src/index.ts', '**/src/server.ts'], }, }, + plugins: [swc.vite()], });