import { Token, token } from './tokens'; /** * ResolverError is thrown by the resolver when a token is not found in a container. */ export class ResolverError extends Error { constructor(message: string) { super(message); Object.setPrototypeOf(this, new.target.prototype); this.name = 'ResolverError'; } } /** * @see https://github.com/mnasyrov/ditox#factory-lifetimes */ export type FactoryScope = 'scoped' | 'singleton' | 'transient'; /** * Options for factory binding. * * `scope` types: * - `singleton` - **This is the default**. The value is created and cached by the most distant parent container which owns the factory function. * - `scoped` - The value is created and cached by the nearest container which owns the factory function. * - `transient` - The value is created every time it is resolved. * * `scoped` and `singleton` scopes can have `onRemoved` callback. It is called when a token is removed from the container. */ export type FactoryOptions = | { scope?: 'scoped' | 'singleton'; onRemoved?: (value: T) => void; } | { scope: 'transient'; }; /** * Dependency container. */ export type Container = { /** * Binds a value for the token */ bindValue(token: Token, value: T): void; /** * Binds a factory for the token. */ bindFactory( token: Token, factory: (container: Container) => T, options?: FactoryOptions, ): void; /** * Checks if the token is registered in the container hierarchy. */ hasToken(token: Token): boolean; /** * Returns a resolved value by the token, or returns `undefined` in case the token is not found. */ get(token: Token): T | undefined; /** * Returns a resolved value by the token or throws `ResolverError` in case the token is not found. */ resolve(token: Token): T; /** * Removes a binding for the token. */ remove(token: Token): void; /** * Removes all bindings in the container. */ removeAll(): void; }; /** * A subset of Container interface that provides read-only access to dependency resolution. * This type is used for parent containers to allow token resolution without exposing mutation methods. */ export type ContainerResolver = Pick; /** @internal */ export const CONTAINER: Token = token('ditox.Container'); /** @internal */ export const PARENT_CONTAINERS: Token> = token( 'ditox.ParentContainer', ); /** @internal */ export const RESOLVER: Token = token('ditox.Resolver'); /** @internal */ const NOT_FOUND = Symbol(); /** @internal */ const DEFAULT_SCOPE: FactoryScope = 'singleton'; /** @internal */ type FactoryContext = { factory: (container: Container) => T; options?: FactoryOptions; }; /** @internal */ type ValuesMap = Map; /** @internal */ export type FactoriesMap = Map>; /** @internal */ export const FACTORIES_MAP: Token = token('ditox.FactoriesMap'); /** @internal */ type Resolver = (token: Token, origin: Container) => T | typeof NOT_FOUND; /** @internal */ function getScope(options?: FactoryOptions): FactoryScope { return options?.scope ?? DEFAULT_SCOPE; } /** @internal */ function getOnRemoved(options: FactoryOptions) { return options.scope === undefined || options.scope === 'scoped' || options.scope === 'singleton' ? options.onRemoved : undefined; } /** @internal */ function isInternalToken(token: Token): boolean { return ( token.symbol === CONTAINER.symbol || token.symbol === PARENT_CONTAINERS.symbol || token.symbol === RESOLVER.symbol ); } /** * Creates a new dependency container. * * Container can have an optional parent to chain token resolution. The parent is used in case the current container does not have a registered token. * * @param parentArg - Optional parent container or an array of containers. */ export function createContainer( parentArg?: ContainerResolver | ReadonlyArray, ): Container { const parents: ReadonlyArray | undefined = parentArg ? Array.isArray(parentArg) ? [...parentArg] : [parentArg] : undefined; const values: ValuesMap = new Map(); const factories: FactoriesMap = new Map>(); const container: Container = { bindValue(token: Token, value: T): void { if (isInternalToken(token)) { return; } values.set(token.symbol, value); }, bindFactory( token: Token, factory: (container: Container) => T, options?: FactoryOptions, ): void { if (isInternalToken(token)) { return; } factories.set(token.symbol, { factory, options }); }, remove(token: Token): void { if (isInternalToken(token)) { return; } const options = factories.get(token.symbol)?.options; if (options) { executeOnRemoved(token.symbol, options); } values.delete(token.symbol); factories.delete(token.symbol); }, removeAll(): void { factories.forEach((context, tokenSymbol) => { if (context.options) { executeOnRemoved(tokenSymbol, context.options); } }); values.clear(); factories.clear(); bindInternalTokens(); }, hasToken(token: Token): boolean { return ( values.has(token.symbol) || factories.has(token.symbol) || parentContainersHaveToken(parents, token) ); }, get(token: Token): T | undefined { const value = resolver(token, container); if (value !== NOT_FOUND) { return value; } if (token.isOptional) { return token.optionalValue; } return undefined; }, resolve(token: Token): T { const value = resolver(token, container); if (value !== NOT_FOUND) { return value; } if (token.isOptional) { return token.optionalValue; } throw new ResolverError( `Token "${token.symbol.description ?? ''}" is not provided`, ); }, }; function resolver( token: Token, origin: Container, ): T | typeof NOT_FOUND { const value = values.get(token.symbol); const hasValue = value !== undefined || values.has(token.symbol); if (hasValue && origin === container) { return value; } const factoryContext = factories.get(token.symbol); if (factoryContext) { const scope = getScope(factoryContext.options); switch (scope) { case 'singleton': { if (hasValue) { return value; } else if (parentContainersHaveToken(parents, token)) { break; } else { // Cache the value in the same container where the factory is registered. const value = factoryContext.factory(container); container.bindValue(token, value); return value; } } case 'scoped': { if (hasValue) { return value; } else { // Create a value within the factory's container and cache it. const value = factoryContext.factory(container); container.bindValue(token, value); return value; } } case 'transient': { // Create a value within the origin container and don't cache it. return factoryContext.factory(origin); } } } if (hasValue) { return value; } if (parents) { return parentContainersResolveToken(parents, token, origin); } return NOT_FOUND; } function executeOnRemoved( tokenSymbol: symbol, options: FactoryOptions, ) { const onRemoved = getOnRemoved(options); if (onRemoved) { const value = values.get(tokenSymbol); if (value !== undefined || values.has(tokenSymbol)) { onRemoved(value); } } } function bindInternalTokens() { values.set(CONTAINER.symbol, container); values.set(RESOLVER.symbol, resolver); values.set(FACTORIES_MAP.symbol, factories); if (parents) { values.set(PARENT_CONTAINERS.symbol, parents); } } bindInternalTokens(); return container; } function parentContainersHaveToken( parents: ReadonlyArray | undefined, token: Token, ): boolean { if (!parents) return false; for (let i = 0; i < parents.length; i++) { if (parents[i].hasToken(token)) { return true; } } return false; } function parentContainersResolveToken( parents: ReadonlyArray, token: Token, origin: Container, ): T | typeof NOT_FOUND { for (const parentContainer of parents) { const parentResolver = parentContainer.get(RESOLVER); if (parentResolver) { const resolved = parentResolver(token, origin); if (resolved !== NOT_FOUND) { return resolved; } } } return NOT_FOUND; }