diff --git a/README.md b/README.md index 0fcae98..6b9094b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ constructor injection. - [Circular dependencies](#circular-dependencies) - [The `delay` helper function](#the-delay-helper-function) - [Interfaces and circular dependencies](#interfaces-and-circular-dependencies) +- [Disposable instances](#disposable-instances) - [Full examples](#full-examples) - [Example without interfaces](#example-without-interfaces) - [Example with interfaces](#example-with-interfaces) @@ -540,6 +541,14 @@ export class Bar implements IBar { } ``` +# Disposable instances +All instances create by the container that implement the [`Disposable`](./src/types/disposable.ts) +interface will automatically be disposed of when the container is disposed. + +```typescript +container.dispose(); +``` + # Full examples ## Example without interfaces diff --git a/src/__tests__/disposable.test.ts b/src/__tests__/disposable.test.ts new file mode 100644 index 0000000..efa838f --- /dev/null +++ b/src/__tests__/disposable.test.ts @@ -0,0 +1,27 @@ +import Disposable, {isDisposable} from "../types/disposable"; + +describe("Disposable", () => { + describe("isDisposable", () => { + it("returns false for non-disposable object", () => { + const nonDisposable = {}; + + expect(isDisposable(nonDisposable)).toBeFalsy(); + }); + + it("returns false when dispose method takes too many args", () => { + const specialDisposable = { + dispose(_: any) {} + }; + + expect(isDisposable(specialDisposable)).toBeFalsy(); + }); + + it("returns true for disposable object", () => { + const disposable: Disposable = { + dispose() {} + }; + + expect(isDisposable(disposable)).toBeTruthy(); + }); + }); +}); diff --git a/src/__tests__/global-container.test.ts b/src/__tests__/global-container.test.ts index 35a9c12..2359a7e 100644 --- a/src/__tests__/global-container.test.ts +++ b/src/__tests__/global-container.test.ts @@ -7,6 +7,7 @@ import {instance as globalContainer} from "../dependency-container"; import injectAll from "../decorators/inject-all"; import Lifecycle from "../types/lifecycle"; import {ValueProvider} from "../providers"; +import Disposable from "../types/disposable"; interface IBar { value: string; @@ -782,3 +783,60 @@ test("predicateAwareClassFactory returns new instances each call with caching of expect(factory(globalContainer)).not.toBe(factory(globalContainer)); }); + +describe("dispose", () => { + class Foo implements Disposable { + disposed = false; + dispose(): void { + this.disposed = true; + } + } + class Bar implements Disposable { + disposed = false; + dispose(): void { + this.disposed = true; + } + } + + it("renders the container useless", () => { + const container = globalContainer.createChildContainer(); + container.dispose(); + + expect(() => container.reset()).toThrow(/disposed/); + }); + + it("disposes all child disposables", () => { + const container = globalContainer.createChildContainer(); + + const foo = container.resolve(Foo); + const bar = container.resolve(Bar); + + container.dispose(); + + expect(foo.disposed).toBeTruthy(); + expect(bar.disposed).toBeTruthy(); + }); + + it("disposes all instances of the same type", () => { + const container = globalContainer.createChildContainer(); + + const foo1 = container.resolve(Foo); + const foo2 = container.resolve(Foo); + + container.dispose(); + + expect(foo1.disposed).toBeTruthy(); + expect(foo2.disposed).toBeTruthy(); + }); + + it("doesn't dispose of instances created external to the container", () => { + const foo = new Foo(); + const container = globalContainer.createChildContainer(); + + container.registerInstance(Foo, foo); + container.resolve(Foo); + container.dispose(); + + expect(foo.disposed).toBeFalsy(); + }); +}); diff --git a/src/dependency-container.ts b/src/dependency-container.ts index d4f472d..022206c 100644 --- a/src/dependency-container.ts +++ b/src/dependency-container.ts @@ -23,6 +23,7 @@ import Lifecycle from "./types/lifecycle"; import ResolutionContext from "./resolution-context"; import {formatErrorCtor} from "./error-helpers"; import {DelayedConstructor} from "./lazy-helpers"; +import Disposable, {isDisposable} from "./types/disposable"; export type Registration = { provider: Provider; @@ -36,7 +37,9 @@ export const typeInfo = new Map, ParamInfo[]>(); /** Dependency Container */ class InternalDependencyContainer implements DependencyContainer { - private _registry = new Registry(); + private registry = new Registry(); + private disposed = false; + private disposables = new Set(); public constructor(private parent?: InternalDependencyContainer) {} @@ -73,6 +76,8 @@ class InternalDependencyContainer implements DependencyContainer { providerOrConstructor: Provider | constructor, options: RegistrationOptions = {lifecycle: Lifecycle.Transient} ): InternalDependencyContainer { + this.ensureNotDisposed(); + let provider: Provider; if (!isProvider(providerOrConstructor)) { @@ -98,7 +103,7 @@ class InternalDependencyContainer implements DependencyContainer { path.push(currentToken); - const registration = this._registry.get(currentToken); + const registration = this.registry.get(currentToken); if (registration && isTokenProvider(registration.provider)) { tokenProvider = registration.provider; @@ -122,7 +127,7 @@ class InternalDependencyContainer implements DependencyContainer { } } - this._registry.set(token, {provider, options}); + this.registry.set(token, {provider, options}); return this; } @@ -131,6 +136,8 @@ class InternalDependencyContainer implements DependencyContainer { from: InjectionToken, to: InjectionToken ): InternalDependencyContainer { + this.ensureNotDisposed(); + if (isNormalToken(to)) { return this.register(from, { useToken: to @@ -146,6 +153,8 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken, instance: T ): InternalDependencyContainer { + this.ensureNotDisposed(); + return this.register(token, { useValue: instance }); @@ -163,6 +172,8 @@ class InternalDependencyContainer implements DependencyContainer { from: InjectionToken, to?: InjectionToken ): InternalDependencyContainer { + this.ensureNotDisposed(); + if (isNormalToken(from)) { if (isNormalToken(to)) { return this.register( @@ -205,6 +216,8 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken, context: ResolutionContext = new ResolutionContext() ): T { + this.ensureNotDisposed(); + const registration = this.getRegistration(token); if (!registration && isNormalToken(token)) { @@ -230,6 +243,8 @@ class InternalDependencyContainer implements DependencyContainer { registration: Registration, context: ResolutionContext ): T { + this.ensureNotDisposed(); + // If we have already resolved this scoped dependency, return it if ( registration.options.lifecycle === Lifecycle.ResolutionScoped && @@ -282,6 +297,8 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken, context: ResolutionContext = new ResolutionContext() ): T[] { + this.ensureNotDisposed(); + const registrations = this.getAllRegistrations(token); if (!registrations && isNormalToken(token)) { @@ -301,8 +318,10 @@ class InternalDependencyContainer implements DependencyContainer { } public isRegistered(token: InjectionToken, recursive = false): boolean { + this.ensureNotDisposed(); + return ( - this._registry.has(token) || + this.registry.has(token) || (recursive && (this.parent || false) && this.parent.isRegistered(token, true)) @@ -310,12 +329,16 @@ class InternalDependencyContainer implements DependencyContainer { } public reset(): void { - this._registry.clear(); + this.ensureNotDisposed(); + + this.registry.clear(); } public clearInstances(): void { - for (const [token, registrations] of this._registry.entries()) { - this._registry.setAll( + this.ensureNotDisposed(); + + for (const [token, registrations] of this.registry.entries()) { + this.registry.setAll( token, registrations // Clear ValueProvider registrations @@ -330,9 +353,11 @@ class InternalDependencyContainer implements DependencyContainer { } public createChildContainer(): DependencyContainer { + this.ensureNotDisposed(); + const childContainer = new InternalDependencyContainer(this); - for (const [token, registrations] of this._registry.entries()) { + for (const [token, registrations] of this.registry.entries()) { // If there are any ContainerScoped registrations, we need to copy // ALL registrations to the child container, if we were to copy just // the ContainerScoped registrations, we would lose access to the others @@ -341,7 +366,7 @@ class InternalDependencyContainer implements DependencyContainer { ({options}) => options.lifecycle === Lifecycle.ContainerScoped ) ) { - childContainer._registry.setAll( + childContainer.registry.setAll( token, registrations.map(registration => { if (registration.options.lifecycle === Lifecycle.ContainerScoped) { @@ -360,9 +385,14 @@ class InternalDependencyContainer implements DependencyContainer { return childContainer; } + public dispose(): void { + this.disposed = true; + this.disposables.forEach(disposable => disposable.dispose()); + } + private getRegistration(token: InjectionToken): Registration | null { if (this.isRegistered(token)) { - return this._registry.get(token)!; + return this.registry.get(token)!; } if (this.parent) { @@ -376,7 +406,7 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken ): Registration[] | null { if (this.isRegistered(token)) { - return this._registry.getAll(token); + return this.registry.getAll(token); } if (this.parent) { @@ -395,18 +425,27 @@ class InternalDependencyContainer implements DependencyContainer { this.resolve(target, context) ); } - const paramInfo = typeInfo.get(ctor); - if (!paramInfo || paramInfo.length === 0) { - if (ctor.length === 0) { - return new ctor(); - } else { - throw new Error(`TypeInfo not known for "${ctor.name}"`); + + const instance: T = (() => { + const paramInfo = typeInfo.get(ctor); + if (!paramInfo || paramInfo.length === 0) { + if (ctor.length === 0) { + return new ctor(); + } else { + throw new Error(`TypeInfo not known for "${ctor.name}"`); + } } - } - const params = paramInfo.map(this.resolveParams(context, ctor)); + const params = paramInfo.map(this.resolveParams(context, ctor)); + + return new ctor(...params); + })(); + + if (isDisposable(instance)) { + this.disposables.add(instance); + } - return new ctor(...params); + return instance; } private resolveParams(context: ResolutionContext, ctor: constructor) { @@ -423,6 +462,14 @@ class InternalDependencyContainer implements DependencyContainer { } }; } + + private ensureNotDisposed(): void { + if (this.disposed) { + throw new Error( + "This container has been disposed, you cannot interact with a disposed container" + ); + } + } } export const instance: DependencyContainer = new InternalDependencyContainer(); diff --git a/src/index.ts b/src/index.ts index c62ce23..3eace31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,12 @@ if (typeof Reflect === "undefined" || !Reflect.getMetadata) { ); } -export {DependencyContainer, Lifecycle, RegistrationOptions} from "./types"; +export { + DependencyContainer, + Lifecycle, + RegistrationOptions, + Disposable +} from "./types"; export * from "./decorators"; export * from "./factories"; export * from "./providers"; diff --git a/src/types/dependency-container.ts b/src/types/dependency-container.ts index 3533b5d..079374d 100644 --- a/src/types/dependency-container.ts +++ b/src/types/dependency-container.ts @@ -5,8 +5,9 @@ import ValueProvider from "../providers/value-provider"; import ClassProvider from "../providers/class-provider"; import constructor from "./constructor"; import RegistrationOptions from "./registration-options"; +import Disposable from "./disposable"; -export default interface DependencyContainer { +export default interface DependencyContainer extends Disposable { register( token: InjectionToken, provider: ValueProvider @@ -54,6 +55,7 @@ export default interface DependencyContainer { * @return An instance of the dependency */ resolve(token: InjectionToken): T; + resolveAll(token: InjectionToken): T[]; /** @@ -71,5 +73,12 @@ export default interface DependencyContainer { reset(): void; clearInstances(): void; + createChildContainer(): DependencyContainer; + + /** + * Calls `.dispose()` on all disposable instances created by the container. + * After calling this, the container may no longer be used. + */ + dispose(): void; } diff --git a/src/types/disposable.ts b/src/types/disposable.ts new file mode 100644 index 0000000..374052d --- /dev/null +++ b/src/types/disposable.ts @@ -0,0 +1,16 @@ +export default interface Disposable { + dispose(): void; +} + +export function isDisposable(value: any): value is Disposable { + if (typeof value.dispose !== "function") return false; + + const disposeFun: Function = value.dispose; + + // `.dispose()` takes in no arguments + if (disposeFun.length > 0) { + return false; + } + + return true; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0d0b4c3..2f9fdd9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,3 +3,4 @@ export {default as DependencyContainer} from "./dependency-container"; export {default as Dictionary} from "./dictionary"; export {default as RegistrationOptions} from "./registration-options"; export {default as Lifecycle} from "./lifecycle"; +export {default as Disposable} from "./disposable";