Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ handler.logger.log('hello from token injection');

Returns the default container or a named container.

Calling `Container.of(id)` with the same identifier reuses the same container
instance until that instance is disposed.

### `container.get(id)`

Resolves a service by class or service identifier.
Expand All @@ -221,6 +224,15 @@ Throws:
- `ServiceNotFoundError` when no registration exists;
- `CircularDependencyError` when the current resolution path loops back to an in-progress dependency.

### `container.tryGet(id)`

Resolves a service by class or service identifier and returns `undefined` when
no registration exists.

Unlike `get()`, this only returns `undefined` when the requested identifier is
not registered anywhere in the current resolution path. Other failures, such as
circular dependencies or missing nested dependencies, still throw.

### `container.has(id)`

Checks whether the current container has a local registration.
Expand All @@ -238,6 +250,19 @@ Supported strategies:

This is especially useful in tests.

### `await container.dispose()`

Disposes the current container instance explicitly.

- clears local registrations, bindings, and cached service instances;
- awaits any bound value or cached service instance that exposes a `dispose()` method;
- makes the disposed container instance unusable for future operations.

After disposal, calling `Container.of(id)` again creates a fresh container for
that identifier. The same applies to the default container: after disposal, the
next `Container.of()` or named-container fallback access recreates a fresh
default container with no previous registrations or cached instances.

### `container.set(id, valueOrProvider)`

Registers or replaces a bound value or provider for a service identifier.
Expand Down
100 changes: 97 additions & 3 deletions src/container/container.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CircularDependencyError, ServiceNotFoundError } from '../errors';
import { CircularDependencyError, ContainerDisposedError, ServiceNotFoundError } from '../errors';
import type {
ClassProvider,
ContainerIdentifier,
Expand Down Expand Up @@ -30,11 +30,38 @@ export class Container {
private bindingMap: Map<ServiceIdentifier, unknown> = new Map();
private resolving = new Set<ServiceIdentifier>();
private resolvingPath: ServiceIdentifier[] = [];
private disposed = false;

constructor(id: ContainerIdentifier) {
this.id = id;
}

private ensureNotDisposed() {
if (this.disposed) {
throw new ContainerDisposedError(this.id);
}
}

private isDisposable(value: unknown): value is { dispose: () => void | Promise<void> } {
return typeof value === 'object' && value !== null && 'dispose' in value && typeof value.dispose === 'function';
}

private canResolveLocally(id: ServiceIdentifier): boolean {
return this.bindingMap.has(id) || this.metadataMap.has(id);
}

private canResolve(id: ServiceIdentifier): boolean {
if (this.canResolveLocally(id)) {
return true;
}

if (this.isDefault()) {
return false;
}

return ContainerRegistry.defaultContainer.canResolveLocally(id);
}

private isValueProvider<T>(provider: T | ServiceProvider<T>): provider is ValueProvider<T> {
return typeof provider === 'object' && provider !== null && 'useValue' in provider;
}
Expand Down Expand Up @@ -77,8 +104,8 @@ export class Container {
/**
* Returns the default container or a named container.
*
* Calling this method with the same identifier always returns the same
* container instance.
* Calling this method with the same identifier returns the same container
* instance until that instance is disposed.
*
* @param id The container identifier. Omit this to use the default container.
* @returns The matching container instance.
Expand Down Expand Up @@ -110,6 +137,8 @@ export class Container {
* @returns The current container.
*/
public register<T>(metadata: Metadata<T>) {
this.ensureNotDisposed();

if (metadata.scope === 'singleton' && !this.isDefault()) {
ContainerRegistry.defaultContainer.register(metadata);
this.metadataMap.delete(metadata.id);
Expand Down Expand Up @@ -141,6 +170,7 @@ export class Container {
* @returns `true` when the current container has a local registration.
*/
public has(id: ServiceIdentifier): boolean {
this.ensureNotDisposed();
return this.bindingMap.has(id) || this.metadataMap.has(id);
}

Expand All @@ -157,6 +187,8 @@ export class Container {
public set<T>(id: ServiceIdentifier<T>, value: T): this;
public set<T>(id: ServiceIdentifier<T>, provider: ServiceProvider<T>): this;
public set<T>(id: ServiceIdentifier<T>, valueOrProvider: T | ServiceProvider<T>) {
this.ensureNotDisposed();

if (this.isValueProvider(valueOrProvider)) {
this.bindingMap.set(id, valueOrProvider.useValue);
this.metadataMap.delete(id);
Expand Down Expand Up @@ -185,6 +217,7 @@ export class Container {
* @returns The current container.
*/
public remove(id: ServiceIdentifier) {
this.ensureNotDisposed();
this.bindingMap.delete(id);
this.metadataMap.delete(id);
return this;
Expand All @@ -200,6 +233,8 @@ export class Container {
* @returns The current container.
*/
public reset(strategy: 'value' | 'service' = 'value') {
this.ensureNotDisposed();

if (strategy === 'value') {
this.metadataMap.forEach((metadata) => {
metadata.value = EMPTY_VALUE;
Expand Down Expand Up @@ -228,6 +263,8 @@ export class Container {
* @throws {CircularDependencyError} If the dependency graph contains a cycle.
*/
public get<T>(id: ServiceIdentifier<T>): T {
this.ensureNotDisposed();

if (this.bindingMap.has(id)) {
return this.bindingMap.get(id) as T;
}
Expand Down Expand Up @@ -299,6 +336,63 @@ export class Container {
}
}

/**
* Resolves a service if it exists, otherwise returns `undefined`.
*
* Unlike `get()`, this only suppresses `ServiceNotFoundError`. Other errors,
* such as circular dependencies or disposed-container access, still surface.
*
* @param id The service identifier to resolve.
* @returns The resolved value, or `undefined` when the service is missing.
*/
public tryGet<T>(id: ServiceIdentifier<T>): T | undefined {
this.ensureNotDisposed();

if (!this.canResolve(id)) {
return undefined;
}

return this.get(id);
}

/**
* Disposes this container instance and clears all local registrations.
*
* The container becomes unusable after disposal. Cached service instances and
* bound values that expose an async or sync `dispose()` method are awaited in
* the order they were discovered.
*/
public async dispose(): Promise<void> {
if (this.disposed) {
return;
}

const ownedValues = new Set<unknown>();

this.bindingMap.forEach((value) => {
ownedValues.add(value);
});

this.metadataMap.forEach((metadata) => {
if (metadata.value !== EMPTY_VALUE) {
ownedValues.add(metadata.value);
}
});

this.disposed = true;
ContainerRegistry.disposeContainer(this);
this.bindingMap.clear();
this.metadataMap.clear();
this.resolving.clear();
this.resolvingPath = [];

for (const value of ownedValues) {
if (this.isDisposable(value)) {
await value.dispose();
}
}
}

private isDefault() {
return this === ContainerRegistry.defaultContainer;
}
Expand Down
14 changes: 14 additions & 0 deletions src/container/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,18 @@ export class ContainerRegistry {

this.containerMap.delete(id);
}

public static disposeContainer(container: Container) {
if (container.id === 'default') {
if (this.defaultContainerInstance === container) {
this.defaultContainerInstance = undefined;
}

return;
}

if (this.containerMap.get(container.id) === container) {
this.containerMap.delete(container.id);
}
}
}
15 changes: 15 additions & 0 deletions src/errors/container-disposed-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ContainerIdentifier } from '../types';

/**
* Thrown when an operation is attempted on a disposed container instance.
*/
export class ContainerDisposedError extends Error {
public name = 'ContainerDisposedError';

/**
* @param id The identifier of the disposed container.
*/
constructor(id: ContainerIdentifier) {
super(`Container has been disposed: ${String(id)}`);
}
}
1 change: 1 addition & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { CircularDependencyError } from './circular-dependency-error';
export { ContainerDuplicatedError } from './container-duplicated-error';
export { ContainerDisposedError } from './container-disposed-error';
export { DefaultContainerIdError } from './default-container-id-error';
export { ServiceNotFoundError } from './service-not-found-error';
Loading
Loading