From dc0e57a4cca5826fd46998d981c1d79322a8e045 Mon Sep 17 00:00:00 2001 From: changchanghwang Date: Thu, 26 Mar 2026 16:34:16 +0900 Subject: [PATCH 1/2] feat: add tryGet and async dispose Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/container/container.ts | 100 +++++++++++++- src/container/registry.ts | 14 ++ src/errors/container-disposed-error.ts | 15 +++ src/errors/index.ts | 1 + test/container/container.test.ts | 179 ++++++++++++++++++++++++- 5 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 src/errors/container-disposed-error.ts diff --git a/src/container/container.ts b/src/container/container.ts index 8cbfdb5..a22d940 100644 --- a/src/container/container.ts +++ b/src/container/container.ts @@ -1,4 +1,4 @@ -import { CircularDependencyError, ServiceNotFoundError } from '../errors'; +import { CircularDependencyError, ContainerDisposedError, ServiceNotFoundError } from '../errors'; import type { ClassProvider, ContainerIdentifier, @@ -30,11 +30,38 @@ export class Container { private bindingMap: Map = new Map(); private resolving = new Set(); 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 } { + 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(provider: T | ServiceProvider): provider is ValueProvider { return typeof provider === 'object' && provider !== null && 'useValue' in provider; } @@ -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. @@ -110,6 +137,8 @@ export class Container { * @returns The current container. */ public register(metadata: Metadata) { + this.ensureNotDisposed(); + if (metadata.scope === 'singleton' && !this.isDefault()) { ContainerRegistry.defaultContainer.register(metadata); this.metadataMap.delete(metadata.id); @@ -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); } @@ -157,6 +187,8 @@ export class Container { public set(id: ServiceIdentifier, value: T): this; public set(id: ServiceIdentifier, provider: ServiceProvider): this; public set(id: ServiceIdentifier, valueOrProvider: T | ServiceProvider) { + this.ensureNotDisposed(); + if (this.isValueProvider(valueOrProvider)) { this.bindingMap.set(id, valueOrProvider.useValue); this.metadataMap.delete(id); @@ -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; @@ -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; @@ -228,6 +263,8 @@ export class Container { * @throws {CircularDependencyError} If the dependency graph contains a cycle. */ public get(id: ServiceIdentifier): T { + this.ensureNotDisposed(); + if (this.bindingMap.has(id)) { return this.bindingMap.get(id) as T; } @@ -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(id: ServiceIdentifier): 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 { + if (this.disposed) { + return; + } + + const ownedValues = new Set(); + + 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; } diff --git a/src/container/registry.ts b/src/container/registry.ts index 3051cca..e004144 100644 --- a/src/container/registry.ts +++ b/src/container/registry.ts @@ -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); + } + } } diff --git a/src/errors/container-disposed-error.ts b/src/errors/container-disposed-error.ts new file mode 100644 index 0000000..9f3eab1 --- /dev/null +++ b/src/errors/container-disposed-error.ts @@ -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)}`); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 805d63c..1c1364f 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -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'; diff --git a/test/container/container.test.ts b/test/container/container.test.ts index 47e2c43..9e420aa 100644 --- a/test/container/container.test.ts +++ b/test/container/container.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { Container } from '../../src'; -import { CircularDependencyError, ServiceNotFoundError } from '../../src/errors'; +import { CircularDependencyError, ContainerDisposedError, ServiceNotFoundError } from '../../src/errors'; import { ContainerRegistry } from '../../src/container/registry'; import { EMPTY_VALUE } from '../../src/types'; @@ -19,6 +19,9 @@ afterEach(() => { ContainerRegistry.removeContainer('container-post-error'); ContainerRegistry.removeContainer('factory-container-scope'); ContainerRegistry.removeContainer('factory-singleton-scope'); + ContainerRegistry.removeContainer('container-try-get'); + ContainerRegistry.removeContainer('container-dispose'); + ContainerRegistry.removeContainer('container-dispose-recreate'); }); describe('Container', () => { @@ -452,4 +455,178 @@ describe('Container', () => { expect(requestContainer.get(HealthyService)).toBeInstanceOf(HealthyService); }); }); + + describe('tryGet', () => { + test('returns undefined when a service is missing', () => { + class MissingService {} + + expect(Container.of().tryGet(MissingService)).toBeUndefined(); + }); + + test('returns undefined when a named container has no local or fallback registration', () => { + expect(Container.of('container-try-get').tryGet('missing')).toBeUndefined(); + }); + + test('returns a resolved service when one exists', () => { + const requestContainer = Container.of('container-try-get'); + + class ExistingService {} + + requestContainer.register({ + id: ExistingService, + Class: ExistingService, + name: 'ExistingService', + injections: [], + scope: 'container', + value: EMPTY_VALUE, + }); + + expect(requestContainer.tryGet(ExistingService)).toBeInstanceOf(ExistingService); + }); + + test('resolves default-container bindings through named-container fallback', () => { + const requestContainer = Container.of('container-try-get'); + + class ExistingService {} + + const bound = new ExistingService(); + + Container.of().set(ExistingService, bound); + + expect(requestContainer.tryGet(ExistingService)).toBe(bound); + }); + + test('does not swallow missing nested dependencies', () => { + class MissingDependency {} + + class ExistingService { + public dependency!: MissingDependency; + } + + Container.of('container-try-get').register({ + id: ExistingService, + Class: ExistingService, + name: 'ExistingService', + injections: [{ id: MissingDependency, name: 'dependency' }], + scope: 'container', + value: EMPTY_VALUE, + }); + + expect(() => Container.of('container-try-get').tryGet(ExistingService)).toThrow(ServiceNotFoundError); + }); + + test('does not swallow circular dependency errors', () => { + class AlphaService { + public beta!: BetaService; + } + + class BetaService { + public alpha!: AlphaService; + } + + Container.of('container-try-get').register({ + id: AlphaService, + Class: AlphaService, + name: 'AlphaService', + injections: [{ id: BetaService, name: 'beta' }], + scope: 'container', + value: EMPTY_VALUE, + }); + + Container.of('container-try-get').register({ + id: BetaService, + Class: BetaService, + name: 'BetaService', + injections: [{ id: AlphaService, name: 'alpha' }], + scope: 'container', + value: EMPTY_VALUE, + }); + + expect(() => Container.of('container-try-get').tryGet(AlphaService)).toThrow(CircularDependencyError); + }); + }); + + describe('dispose', () => { + test('awaits disposal of cached services and bound values, then makes the container unusable', async () => { + const requestContainer = Container.of('container-dispose'); + const events: string[] = []; + + class DisposableBinding { + public async dispose() { + events.push('binding'); + } + } + + class DisposableService { + public async dispose() { + events.push('service'); + } + } + + requestContainer.register({ + id: DisposableService, + Class: DisposableService, + name: 'DisposableService', + injections: [], + scope: 'container', + value: EMPTY_VALUE, + }); + + requestContainer.set(DisposableBinding, new DisposableBinding()); + requestContainer.get(DisposableService); + + await requestContainer.dispose(); + + expect(events).toEqual(['binding', 'service']); + expect(() => requestContainer.get(DisposableService)).toThrow(ContainerDisposedError); + expect(() => requestContainer.tryGet(DisposableService)).toThrow(ContainerDisposedError); + expect(() => requestContainer.set('value', 1)).toThrow(ContainerDisposedError); + expect(() => requestContainer.has(DisposableBinding)).toThrow(ContainerDisposedError); + }); + + test('detaches disposed containers so the same id can be recreated later', async () => { + const first = Container.of('container-dispose-recreate'); + + await first.dispose(); + + const recreated = Container.of('container-dispose-recreate'); + + expect(recreated).not.toBe(first); + expect(() => first.get('missing')).toThrow(ContainerDisposedError); + expect(recreated.tryGet('missing')).toBeUndefined(); + }); + + test('is idempotent', async () => { + const requestContainer = Container.of('container-dispose'); + + await requestContainer.dispose(); + await expect(requestContainer.dispose()).resolves.toBeUndefined(); + }); + + test('recreates the default container after disposal', async () => { + const first = Container.of(); + + class DisposableService { + public dispose() {} + } + + first.register({ + id: DisposableService, + Class: DisposableService, + name: 'DisposableService', + injections: [], + scope: 'container', + value: EMPTY_VALUE, + }); + + first.get(DisposableService); + await first.dispose(); + + const recreated = Container.of(); + + expect(recreated).not.toBe(first); + expect(() => first.get(DisposableService)).toThrow(ContainerDisposedError); + expect(recreated.tryGet(DisposableService)).toBeUndefined(); + }); + }); }); From 88f8fd8132235aa0c382651278ebd41c1afc3120 Mon Sep 17 00:00:00 2001 From: changchanghwang Date: Thu, 26 Mar 2026 16:34:27 +0900 Subject: [PATCH 2/2] docs: document tryGet and dispose Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 5c8ced3..4c63c1c 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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.