diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d42ff..4200acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @evyweb/ioctopus +## 0.3.0 + +### Minor Changes + +- Modules support +- Scopes support + ## 0.2.2 ### Patch Changes diff --git a/README.md b/README.md index ec11b92..dd91e5b 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,127 @@ myUseCase.execute(); ``` Code used in the examples can be found in the specs folder. + +### Modules + +You can also use modules to organize your dependencies. + +#### Loading modules + +Modules can then be loaded in your container. +By default, when you create a container, it is using a default module under the hood. + +```typescript +const module1 = createModule(); +module1.bind(DI.DEP1).toValue('dependency1'); + +const module2 = createModule(); +module2.bind(DI.DEP2).toValue(42); + +const module3 = createModule(); +module3.bind(DI.MY_SERVICE).toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); + +const container = createContainer(); +container.load(Symbol('module1'), module1); +container.load(Symbol('module2'), module2); +container.load(Symbol('module3'), module3); + +const myService = container.get(DI.MY_SERVICE); +``` +The dependencies do not need to be registered in the same module as the one that is using them. +Note that the module name used as a key is a symbol. + +#### Modules override + +You can also override dependencies of a module. The dependencies of the module will be overridden by the dependencies of the last loaded module. + +```typescript +const module1 = createModule(); +module1.bind(DI.DEP1).toValue('OLD dependency1'); +module1.bind(DI.MY_SERVICE).toFunction(sayHelloWorld); + +const module2 = createModule(); +module2.bind(DI.DEP1).toValue('NEW dependency1'); + +const module3 = createModule(); +module3.bind(DI.MY_SERVICE).toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); + +const container = createContainer(); +container.bind(DI.DEP2).toValue(42); // Default module +container.load(Symbol('module1'), module1); +container.load(Symbol('module2'), module2); +container.load(Symbol('module3'), module3); + +// The dependency DI.MY_SERVICE will be resolved with the higher order function and dep1 will be 'NEW dependency1' +const myService = container.get(DI.MY_SERVICE); +``` + +#### Unload modules + +You can also unload a module from the container. The dependencies of the module will be removed from the container. +Already cached instances will be removed to keep consistency and avoid potential errors. + +```typescript +const module1 = createModule(); +module1.bind(DI.DEP1).toValue('dependency1'); + +const container = createContainer(); +container.load(Symbol('module1'), module1); + +container.unload(Symbol('module1')); + +// Will throw an error as the dependency is not registered anymore +const myService = container.get(DI.DEP1); +``` +### Using scopes + +#### Singleton scope (default) + +In singleton scope, the container returns the same instance every time a dependency is resolved. + +```typescript +container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2]); +// or +container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'singleton'); + +const instance1 = container.get(DI.MY_SERVICE); +const instance2 = container.get(DI.MY_SERVICE); + +console.log(instance1 === instance2); // true +``` +#### Transient scope + +In transient scope, the container returns a new instance every time the dependency is resolved. + +```typescript +container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'transient'); + +const instance1 = container.get(DI.MY_SERVICE); +const instance2 = container.get(DI.MY_SERVICE); + +console.log(instance1 === instance2); // false +``` + +#### Scoped Scope +In scoped scope, the container returns the same instance within a scope. Different scopes will have different instances. + +To use the scoped scope, you need to create a scope using runInScope. + +```typescript +container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'scoped'); + +container.runInScope(() => { + const instance1 = container.get(DI.MY_SERVICE); + const instance2 = container.get(DI.MY_SERVICE); + + console.log(instance1 === instance2); // true +}); + +container.runInScope(() => { + const instance3 = container.get(DI.MY_SERVICE); + + console.log(instance3 === instance1); // false +}); +``` + +Note: If you try to resolve a scoped dependency outside a scope, an error will be thrown. diff --git a/package.json b/package.json index 6d176d2..912a017 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@evyweb/ioctopus", - "version": "0.2.2", + "version": "0.3.0", "description": "A simple IoC container for JavaScript and TypeScript for classes and functions.", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/specs/container.spec.ts b/specs/container.spec.ts index 66f3e1e..a71287a 100644 --- a/specs/container.spec.ts +++ b/specs/container.spec.ts @@ -1,18 +1,18 @@ -import {MyService} from "./MyService"; -import {MyServiceInterface} from "./MyServiceInterface"; -import {SayHelloType} from "./SayHelloType"; -import {sayHelloWorld} from "./sayHelloWorld"; -import {MyUseCase} from "./MyUseCase"; -import {MyUseCaseInterface} from "./MyUseCaseInterface"; -import {LoggerInterface} from "./LoggerInterface"; -import {DI} from "./DI"; +import {MyService} from "./examples/MyService"; +import {MyServiceInterface} from "./examples/MyServiceInterface"; +import {SayHelloType} from "./examples/SayHelloType"; +import {sayHelloWorld} from "./examples/sayHelloWorld"; +import {MyUseCase} from "./examples/MyUseCase"; +import {MyUseCaseInterface} from "./examples/MyUseCaseInterface"; +import {LoggerInterface} from "./examples/LoggerInterface"; +import {DI} from "./examples/DI"; import {Container, createContainer} from "../src"; -import {MyServiceClass} from "./MyServiceClass"; -import {MyServiceClassInterface} from "./MyServiceClassInterface"; -import {FunctionWithDependencies} from "./FunctionWithDependencies"; -import {HigherOrderFunctionWithoutDependencies} from "./HigherOrderFunctionWithoutDependencies"; -import {ServiceWithoutDependencyInterface} from "./ServiceWithoutDependencyInterface"; -import {MyServiceClassWithoutDependencies} from "./MyServiceClassWithoutDependencies"; +import {MyServiceClass} from "./examples/MyServiceClass"; +import {MyServiceClassInterface} from "./examples/MyServiceClassInterface"; +import {FunctionWithDependencies} from "./examples/FunctionWithDependencies"; +import {HigherOrderFunctionWithoutDependencies} from "./examples/HigherOrderFunctionWithoutDependencies"; +import {ServiceWithoutDependencyInterface} from "./examples/ServiceWithoutDependencyInterface"; +import {MyServiceClassWithoutDependencies} from "./examples/MyServiceClassWithoutDependencies"; import {mock, MockProxy} from "vitest-mock-extended"; describe('Container', () => { @@ -80,9 +80,9 @@ describe('Container', () => { }); describe.each([ - { dependencies: undefined }, - { dependencies: [] }, - { dependencies: {} }, + {dependencies: undefined}, + {dependencies: []}, + {dependencies: {}}, ])('When the function is a higher order function without dependencies', ({dependencies}) => { it('should just return the function', () => { // Arrange @@ -154,31 +154,6 @@ describe('Container', () => { expect(fakeLogger.log).toHaveBeenCalledWith('hello world'); }); }); - - describe('When the dependency is retrieved twice', () => { - it('should return the same instance', () => { - // Arrange - const factoryCalls = vi.fn(); - container.bind(DI.DEP1).toValue('dependency1'); - container.bind(DI.DEP2).toValue(42); - - container.bind(DI.MY_SERVICE).toFactory(() => { - factoryCalls(); - return MyService({ - dep1: container.get(DI.DEP1), - dep2: container.get(DI.DEP2) - }); - }); - const myService1 = container.get(DI.MY_SERVICE); - - // Act - const myService2 = container.get(DI.MY_SERVICE); - - // Assert - expect(myService1).toBe(myService2); - expect(factoryCalls).toHaveBeenCalledTimes(1); - }); - }); }); }); @@ -201,8 +176,6 @@ describe('Container', () => { describe('When the class has no dependency', () => { it('should just return the instance', () => { // Arrange - container.bind(DI.DEP1).toValue('dependency1'); - container.bind(DI.DEP2).toValue(42); container.bind(DI.CLASS_WITHOUT_DEPENDENCIES).toClass(MyServiceClassWithoutDependencies); // Act @@ -212,22 +185,6 @@ describe('Container', () => { expect(myService.runTask()).toBe('Executing without dependencies'); }); }); - - describe('When the instance is retrieved twice', () => { - it('should always return the same instance', () => { - // Arrange - container.bind(DI.DEP1).toValue('dependency1'); - container.bind(DI.DEP2).toValue(42); - container.bind(DI.CLASS_WITH_DEPENDENCIES).toClass(MyServiceClass, [DI.DEP1, DI.DEP2]); - const myService1 = container.get(DI.CLASS_WITH_DEPENDENCIES); - - // Act - const myService2 = container.get(DI.CLASS_WITH_DEPENDENCIES); - - // Assert - expect(myService1).toBe(myService2); - }); - }); }); describe('When no dependency has been registered', () => { @@ -237,4 +194,4 @@ describe('Container', () => { .toThrowError(`No binding found for key: ${DI.NOT_REGISTERED_VALUE.toString()}`); }); }); -}); \ No newline at end of file +}); diff --git a/specs/DI.ts b/specs/examples/DI.ts similarity index 93% rename from specs/DI.ts rename to specs/examples/DI.ts index 082dff3..51d8b9f 100644 --- a/specs/DI.ts +++ b/specs/examples/DI.ts @@ -1,4 +1,4 @@ -import {InjectionTokens} from "../src"; +import {InjectionTokens} from "../../src"; export const DI: InjectionTokens = { DEP1: Symbol('DEP1'), diff --git a/specs/FunctionWithDependencies.ts b/specs/examples/FunctionWithDependencies.ts similarity index 100% rename from specs/FunctionWithDependencies.ts rename to specs/examples/FunctionWithDependencies.ts diff --git a/specs/HigherOrderFunctionWithoutDependencies.ts b/specs/examples/HigherOrderFunctionWithoutDependencies.ts similarity index 100% rename from specs/HigherOrderFunctionWithoutDependencies.ts rename to specs/examples/HigherOrderFunctionWithoutDependencies.ts diff --git a/specs/LoggerInterface.ts b/specs/examples/LoggerInterface.ts similarity index 100% rename from specs/LoggerInterface.ts rename to specs/examples/LoggerInterface.ts diff --git a/specs/MyService.ts b/specs/examples/MyService.ts similarity index 100% rename from specs/MyService.ts rename to specs/examples/MyService.ts diff --git a/specs/MyServiceClass.ts b/specs/examples/MyServiceClass.ts similarity index 100% rename from specs/MyServiceClass.ts rename to specs/examples/MyServiceClass.ts diff --git a/specs/MyServiceClassInterface.ts b/specs/examples/MyServiceClassInterface.ts similarity index 100% rename from specs/MyServiceClassInterface.ts rename to specs/examples/MyServiceClassInterface.ts diff --git a/specs/MyServiceClassWithoutDependencies.ts b/specs/examples/MyServiceClassWithoutDependencies.ts similarity index 100% rename from specs/MyServiceClassWithoutDependencies.ts rename to specs/examples/MyServiceClassWithoutDependencies.ts diff --git a/specs/MyServiceInterface.ts b/specs/examples/MyServiceInterface.ts similarity index 100% rename from specs/MyServiceInterface.ts rename to specs/examples/MyServiceInterface.ts diff --git a/specs/MyUseCase.ts b/specs/examples/MyUseCase.ts similarity index 100% rename from specs/MyUseCase.ts rename to specs/examples/MyUseCase.ts diff --git a/specs/MyUseCaseInterface.ts b/specs/examples/MyUseCaseInterface.ts similarity index 100% rename from specs/MyUseCaseInterface.ts rename to specs/examples/MyUseCaseInterface.ts diff --git a/specs/SayHelloType.ts b/specs/examples/SayHelloType.ts similarity index 100% rename from specs/SayHelloType.ts rename to specs/examples/SayHelloType.ts diff --git a/specs/ServiceWithoutDependencyInterface.ts b/specs/examples/ServiceWithoutDependencyInterface.ts similarity index 100% rename from specs/ServiceWithoutDependencyInterface.ts rename to specs/examples/ServiceWithoutDependencyInterface.ts diff --git a/specs/sayHelloWorld.ts b/specs/examples/sayHelloWorld.ts similarity index 100% rename from specs/sayHelloWorld.ts rename to specs/examples/sayHelloWorld.ts diff --git a/specs/module.spec.ts b/specs/module.spec.ts new file mode 100644 index 0000000..8e1b4a7 --- /dev/null +++ b/specs/module.spec.ts @@ -0,0 +1,129 @@ +import {Container, createContainer, createModule} from "../src"; +import {DI} from "./examples/DI"; +import {sayHelloWorld} from "./examples/sayHelloWorld"; +import {SayHelloType} from "./examples/SayHelloType"; +import {MyService} from "./examples/MyService"; +import {MyServiceInterface} from "./examples/MyServiceInterface"; + +describe('Module', () => { + + let container: Container; + + beforeEach(() => { + container = createContainer(); + }); + + describe('When a module is loaded', () => { + it('should return all module dependencies', () => { + // Arrange + const myModule = createModule(); + myModule.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); + container.load(Symbol('myModule'), myModule); + + // Act + const sayHello = container.get(DI.SIMPLE_FUNCTION); + + // Assert + expect(sayHello()).toBe('hello world'); + }); + + describe('When a dependency of the module is registered in another module', () => { + it('should correctly resolve all dependencies', () => { + // Arrange + const module1 = createModule(); + module1.bind(DI.DEP1).toValue('dependency1'); + + const module2 = createModule(); + module2.bind(DI.DEP2).toValue(42); + + const module3 = createModule(); + module3.bind(DI.MY_SERVICE).toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); + + container.load(Symbol('module1'), module1); + container.load(Symbol('module2'), module2); + container.load(Symbol('module3'), module3); + + // Act + const myService = container.get(DI.MY_SERVICE); + + // Assert + expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); + }); + + it('should take the last registered values', () => { + // Arrange + const module1 = createModule(); + module1.bind(DI.DEP1).toValue('OLD dependency1'); + module1.bind(DI.MY_SERVICE).toFunction(sayHelloWorld); + + const module2 = createModule(); + module2.bind(DI.DEP1).toValue('NEW dependency1'); + + const module3 = createModule(); + module3.bind(DI.MY_SERVICE).toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); + + container.bind(DI.DEP2).toValue(42); + container.load(Symbol('module1'), module1); + container.load(Symbol('module2'), module2); + container.load(Symbol('module3'), module3); + + // Act + const myService = container.get(DI.MY_SERVICE); + + // Assert + expect(myService.runTask()).toBe('Executing with dep1: NEW dependency1 and dep2: 42'); + }); + }); + }); + + describe('When a module is unloaded', () => { + describe('When another module has this dependency already registered', () => { + it('should use the existing dependency', () => { + // Arrange + const MODULE1 = Symbol('myModule1'); + const MODULE2 = Symbol('myModule2'); + + const module1 = createModule(); + module1.bind(DI.SIMPLE_FUNCTION).toFunction(() => { + return 'module 1 hello world'; + }); + container.load(MODULE1, module1); + + const module2 = createModule(); + module2.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); + container.load(MODULE2, module2); + + const sayHelloBeforeUnload = container.get(DI.SIMPLE_FUNCTION); + expect(sayHelloBeforeUnload()).toBe('hello world'); + + // Act + container.unload(MODULE2); + + // Assert + const sayHelloAfterUnload = container.get(DI.SIMPLE_FUNCTION); + expect(sayHelloAfterUnload()).toBe('module 1 hello world'); + }); + }); + + describe('When no other module has this dependency already registered', () => { + it('should remove all its dependencies', () => { + // Arrange + const MY_MODULE = Symbol('myModule'); + + const module = createModule(); + module.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); + container.load(MY_MODULE, module); + + const sayHelloBeforeUnload = container.get(DI.SIMPLE_FUNCTION); + expect(sayHelloBeforeUnload()).toBe('hello world'); + + // Act + container.unload(MY_MODULE); + + // Assert + expect(() => container.get(DI.SIMPLE_FUNCTION)) + .toThrowError(`No binding found for key: ${DI.SIMPLE_FUNCTION.toString()}`); + }); + }); + }); +}); \ No newline at end of file diff --git a/specs/scope.spec.ts b/specs/scope.spec.ts new file mode 100644 index 0000000..3401510 --- /dev/null +++ b/specs/scope.spec.ts @@ -0,0 +1,226 @@ +import {Container, createContainer, Scope} from "../src"; +import {DI} from "./examples/DI"; +import {MyService} from "./examples/MyService"; +import {MyServiceInterface} from "./examples/MyServiceInterface"; +import {Mock, vi} from "vitest"; +import {MyServiceClass} from "./examples/MyServiceClass"; +import {MyServiceClassInterface} from "./examples/MyServiceClassInterface"; + +describe('Scope', () => { + + let container: Container; + let factoryCalls: Mock; + + beforeEach(() => { + container = createContainer(); + container.bind(DI.DEP1).toValue('dependency1'); + container.bind(DI.DEP2).toValue(42); + factoryCalls = vi.fn(); + }); + + describe('Factories', () => { + + describe.each([ + {scope: undefined}, + {scope: 'singleton'}, + ])('When the scope is default or defined to "singleton"', ({scope}) => { + it('should return the same instance', () => { + // Arrange + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, scope as Scope); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(1); + }); + }); + + describe('When the scope is defined to "transient"', () => { + it('should return a new instance each time', () => { + // Arrange + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, 'transient'); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).not.toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(2); + }); + }); + + describe('When the scope is defined to "scoped"', () => { + it('should return the same instance within the same scope', () => { + // Arrange + container.bind(DI.DEP1).toValue('dependency1'); + container.bind(DI.DEP2).toValue(42); + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, 'scoped'); + + let myService1: MyServiceInterface | undefined; + let myService2: MyServiceInterface | undefined; + + // Act + container.runInScope(() => { + myService1 = container.get(DI.MY_SERVICE); + myService2 = container.get(DI.MY_SERVICE); + }); + + // Assert + expect(myService1).toBeDefined(); + expect(myService2).toBeDefined(); + expect(myService1).toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(1); + }); + + it('should return different instances in different scopes', () => { + // Arrange + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, 'scoped'); + + let myService1: MyServiceInterface | undefined; + let myService2: MyServiceInterface | undefined; + + container.runInScope(() => { + myService1 = container.get(DI.MY_SERVICE); + }); + + // Act + container.runInScope(() => { + myService2 = container.get(DI.MY_SERVICE); + }); + + // Assert + expect(myService1).toBeDefined(); + expect(myService2).toBeDefined(); + expect(myService1).not.toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('Classes', () => { + + describe.each([ + {scope: undefined}, + {scope: 'singleton'}, + ])('When the scope is default or defined to "singleton"', ({scope}) => { + it('should return the same instance', () => { + // Arrange + container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], scope as Scope); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).toBe(myService2); + }); + }); + + describe('When the scope is defined to "transient"', () => { + it('should return a new instance each time', () => { + // Arrange + container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'transient'); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).not.toBe(myService2); + }); + }); + }); + + describe('Higher order functions', () => { + + describe.each([ + {scope: undefined}, + {scope: 'singleton'}, + ])('When the scope is default or defined to "singleton"', ({scope}) => { + it('should return the same instance', () => { + // Arrange + container.bind(DI.MY_SERVICE) + .toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}, scope as Scope); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).toBe(myService2); + }); + }); + + describe('When the scope is defined to "transient"', () => { + it('should return a new instance each time', () => { + // Arrange + container.bind(DI.MY_SERVICE) + .toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}, 'transient'); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).not.toBe(myService2); + }); + }); + }); + + describe('When a scoped dependency is resolved outside of a scope', () => { + it('should throw an error', () => { + // Arrange + container.bind(DI.MY_SERVICE) + .toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}, 'scoped'); + + // Act & Assert + expect(() => container.get(DI.MY_SERVICE)) + .toThrowError(`Cannot resolve scoped binding outside of a scope: ${DI.MY_SERVICE.toString()}`); + }); + }); + + describe('When an unknown scope is used during binding', () => { + it('should throw an error', () => { + // Arrange + container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [], 'unknown' as any); + + // Act & Assert + expect(() => container.get(DI.MY_SERVICE)) + .toThrowError('Unknown scope: unknown'); + }); + }); +}); diff --git a/src/container.ts b/src/container.ts index 7885395..6e4cbac 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,72 +1,85 @@ -import {Container, DependencyArray, DependencyObject} from "./types"; +import { Binding, Container, Module } from "./types"; +import { createModule } from "./module"; export function createContainer(): Container { - const values = new Map(); - const factories = new Map(); - const instances = new Map(); + const modules = new Map(); + const singletonInstances = new Map(); + const scopedInstances = new Map>(); + let currentScopeId: symbol | undefined; - const resolveDependenciesArray = (dependencies: DependencyArray) => dependencies.map((dependency) => get(dependency)); + const DEFAULT_MODULE_KEY = Symbol("DEFAULT"); + const defaultModule = createModule(); + modules.set(DEFAULT_MODULE_KEY, defaultModule); - const resolveDependenciesObject = (dependencies: DependencyObject) => { - const entries = Object.entries(dependencies); - return Object.fromEntries(entries.map(([key, dependency]) => [key, get(dependency)])); - }; + const bind = (key: symbol) => defaultModule.bind(key); + + const load = (moduleKey: symbol, module: Module) => modules.set(moduleKey, module); - const isDependencyArray = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyArray => Array.isArray(dependencies); + const unload = (moduleKey: symbol) => { + singletonInstances.clear(); + modules.delete(moduleKey); + }; - const isDependencyObject = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyObject => typeof dependencies === 'object' && !Array.isArray(dependencies); + const findLastBinding = (key: symbol): Binding | null => { + const modulesArray = Array.from(modules.values()); + for (let i = modulesArray.length - 1; i >= 0; i--) { + const module = modulesArray[i]; + const binding = module.bindings.get(key); + if (binding) { + return binding as Binding; + } + } + return null; + }; - function bind(key: symbol) { - const toValue = (value: unknown) => values.set(key, value); + const get = (key: symbol): T => { + const binding = findLastBinding(key); + if (!binding) throw new Error(`No binding found for key: ${key.toString()}`); - const toFunction = (fn: CallableFunction) => factories.set(key, () => fn); + const { factory, scope } = binding; - const toHigherOrderFunction = (fn: CallableFunction, dependencies?: DependencyArray | DependencyObject) => { - if(dependencies) { - if (isDependencyArray(dependencies)) { - factories.set(key, () => fn(...resolveDependenciesArray(dependencies))); - } else if (isDependencyObject(dependencies)) { - factories.set(key, () => fn({...resolveDependenciesObject(dependencies)})); - } else { - throw new Error('Invalid dependencies type'); - } - } else { - factories.set(key, () => fn()); + if (scope === "singleton") { + if (!singletonInstances.has(key)) { + singletonInstances.set(key, factory(resolveDependency)); } - }; - - const toFactory = (factory: CallableFunction) => factories.set(key, factory); - - const toClass = (AnyClass: new (...args: unknown[]) => unknown, dependencies: DependencyArray = []) => { - factories.set(key, () => new AnyClass(...resolveDependenciesArray(dependencies))); - }; - - return { - toValue, - toFunction, - toFactory, - toClass, - toHigherOrderFunction - }; - } - - function get(key: symbol): T { - if (values.has(key)) { - return values.get(key) as T; + return singletonInstances.get(key) as T; } - if (instances.has(key)) { - return instances.get(key) as T; + if (scope === "transient") { + return factory(resolveDependency) as T; } - if (factories.has(key)) { - const factory = factories.get(key)!; - instances.set(key, factory()); - return instances.get(key) as T; + if (scope === "scoped") { + if (!currentScopeId) throw new Error(`Cannot resolve scoped binding outside of a scope: ${key.toString()}`); + + if (!scopedInstances.has(currentScopeId)) { + scopedInstances.set(currentScopeId, new Map()); + } + const scopeMap = scopedInstances.get(currentScopeId)!; + if (!scopeMap.has(key)) { + scopeMap.set(key, factory(resolveDependency)); + } + + return scopeMap.get(key) as T; } - throw new Error(`No binding found for key: ${key.toString()}`); - } + throw new Error(`Unknown scope: ${scope}`); + }; + + const resolveDependency = (depKey: symbol): unknown => { + return get(depKey); + }; + + const runInScope = (callback: () => T): T => { + const previousScopeId = currentScopeId; + currentScopeId = Symbol("scope"); + try { + return callback(); + } finally { + scopedInstances.delete(currentScopeId); + currentScopeId = previousScopeId; + } + }; - return {bind, get}; -} \ No newline at end of file + return { bind, load, get, unload, runInScope }; +} diff --git a/src/index.ts b/src/index.ts index d1aae67..d50ca87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './container'; +export * from './module'; export * from './types'; \ No newline at end of file diff --git a/src/module.ts b/src/module.ts new file mode 100644 index 0000000..996dd45 --- /dev/null +++ b/src/module.ts @@ -0,0 +1,85 @@ +import { DependencyArray, DependencyObject, Module, ResolveFunction, Scope } from "./types"; + +interface Binding { + factory: (resolve: ResolveFunction) => unknown; + scope: Scope; +} + +export function createModule(): Module { + const bindings = new Map(); + + const resolveDependenciesArray = (dependencies: DependencyArray, resolve: ResolveFunction) => + dependencies.map(resolve); + + const resolveDependenciesObject = (dependencies: DependencyObject, resolve: ResolveFunction) => { + const entries = Object.entries(dependencies); + return Object.fromEntries(entries.map(([key, dependency]) => [key, resolve(dependency)])); + }; + + const isDependencyArray = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyArray => + Array.isArray(dependencies); + + const isDependencyObject = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyObject => + dependencies !== null && typeof dependencies === "object" && !Array.isArray(dependencies); + + const bind = (key: symbol) => { + const toValue = (value: unknown) => { + bindings.set(key, { factory: () => value, scope: 'singleton' }); + }; + + const toFunction = (fn: CallableFunction) => { + bindings.set(key, { factory: () => fn, scope: 'singleton' }); + }; + + const toHigherOrderFunction = ( + fn: CallableFunction, + dependencies?: DependencyArray | DependencyObject, + scope: Scope = 'singleton' + ) => { + if (dependencies && !isDependencyArray(dependencies) && !isDependencyObject(dependencies)) { + throw new Error("Invalid dependencies type"); + } + + const factory = (resolve: ResolveFunction) => { + if (!dependencies) { + return fn(); + } + + if (isDependencyArray(dependencies)) { + return fn(...resolveDependenciesArray(dependencies, resolve)); + } + + return fn({ ...resolveDependenciesObject(dependencies, resolve) }); + }; + + bindings.set(key, { factory, scope }); + }; + + const toFactory = (factory: CallableFunction, scope: Scope = 'singleton') => { + bindings.set(key, { factory: (resolve: ResolveFunction) => factory(resolve), scope }); + }; + + const toClass = ( + AnyClass: new (...args: unknown[]) => unknown, + dependencies: DependencyArray = [], + scope: Scope = 'singleton' + ) => { + const factory = (resolve: ResolveFunction) => { + const resolvedDeps = dependencies.map((dep) => resolve(dep)); + return new AnyClass(...resolvedDeps); + }; + + bindings.set(key, { factory, scope }); + }; + + return { + toValue, + toFunction, + toFactory, + toClass, + toHigherOrderFunction, + }; + }; + + return { bind, bindings }; +} diff --git a/src/types.ts b/src/types.ts index 6f01874..a111a40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,18 +4,61 @@ export interface DependencyObject { export type DependencyArray = symbol[]; +export type Scope = 'singleton' | 'transient' | 'scoped'; + export interface Container { bind(key: symbol): { toValue: (value: unknown) => void; toFunction: (fn: CallableFunction) => void; - toHigherOrderFunction: (fn: CallableFunction, dependencies?: DependencyArray | DependencyObject) => void; - toFactory: (factory: CallableFunction) => void; - toClass: (constructor: new (...args: any[]) => C, dependencies?: DependencyArray) => void; + toHigherOrderFunction: ( + fn: CallableFunction, + dependencies?: DependencyArray | DependencyObject, + scope?: Scope + ) => void; + toFactory: (factory: CallableFunction, scope?: Scope) => void; + toClass: ( + constructor: new (...args: any[]) => C, + dependencies?: DependencyArray, + scope?: Scope + ) => void; }; + load(moduleKey: symbol, module: Module): void; + get(key: symbol): T; + + unload(key: symbol): void; + + runInScope(callback: () => T): T; +} + +export interface Module { + bind(key: symbol): { + toValue: (value: unknown) => void; + toFunction: (fn: CallableFunction) => void; + toHigherOrderFunction: ( + fn: CallableFunction, + dependencies?: DependencyArray | DependencyObject, + scope?: Scope + ) => void; + toFactory: (factory: CallableFunction, scope?: Scope) => void; + toClass: ( + constructor: new (...args: any[]) => C, + dependencies?: DependencyArray, + scope?: Scope + ) => void; + }; + + bindings: Map; } export interface InjectionTokens { [key: string]: symbol; } + +export type ResolveFunction = (dep: symbol) => unknown; + +export interface Binding { + factory: (resolve: (key: symbol) => unknown) => unknown; + scope: Scope; +} \ No newline at end of file