import { UnknownProviderError } from "./errors.ts"; import { getLatestMetadata, getMetadata } from "./metadata.ts"; export type Type = new (...args: never[]) => T; export type AbstractType = abstract new (...args: never[]) => T; export type Token = Type | AbstractType | symbol | string; export type ProviderScope = "singleton" | "request" | "transient"; export interface ProviderDefinition { /** Token the provider is registered under, defaults to `useClass`. */ provide?: Token; /** Static value. */ useValue?: T; /** Class instantiated by the container. */ useClass?: AbstractType; /** Factory function, dependencies are resolved through `inject`. */ useFactory?: (...dependencies: unknown[]) => T; /** Tokens handed to `useFactory` (or used for `useClass` overrides). */ inject?: Token[]; /** Lifetime of the provider. */ scope?: ProviderScope; } interface FactoryEntry { token: Token; scope: ProviderScope; owner: Container; produce: (scope?: Container) => unknown; /** Asynchronous variant, used by {@link Container.resolveAsync}. */ produceAsync?: (scope?: Container) => Promise; } export const PARAM_TYPES_METADATA = "design:paramtypes"; export const INJECT_TOKENS_METADATA = "muddle:inject"; export const OPTIONAL_PARAMS_METADATA = "muddle:optional"; interface InjectEntry { index: number; token: Token; } /** Reads the `@Inject()` overrides of a constructor. */ export function getInjectTokens(target: object): Map { const entries = getLatestMetadata(INJECT_TOKENS_METADATA, target) as | InjectEntry[] | undefined; const map = new Map(); for (const entry of entries ?? []) { map.set(entry.index, entry.token); } return map; } /** Reads the parameters marked with `@Optional()`. */ export function getOptionalParams(target: object): Set { const entries = getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as | number[] | undefined; return new Set(entries ?? []); } export class Container { private readonly factories = new Map(); readonly instances = new Map(); /** Called when a token cannot be found in this container or its parents. */ fallbackResolver?: (token: Token, scope?: Container) => unknown; constructor(readonly parent?: Container) {} /** Creates a child container that shares factories but isolates instances. */ createScope(): Container { const scope = new Container(this); scope.fallbackResolver = (token, inner) => this.lookupFallback(token, inner); return scope; } /** Registers a factory entry. */ set(token: Token, entry: Omit): void { this.factories.set(token, { ...entry, token, owner: this }); } /** `true` when the token is known to this container or one of its parents. */ has(token: Token): boolean { return this.findEntry(token) !== undefined; } /** Every token known to this container. */ tokens(): Token[] { return [...this.factories.keys()]; } /** * Resolves a token. * Providers built with an asynchronous factory return a promise, use * {@link resolveAsync} to await them. */ resolve(token: Token, scope?: Container): T { const entry = this.findEntry(token); if (!entry) { const fallback = this.lookupFallback(token, scope); if (fallback !== undefined) return fallback as T; throw new UnknownProviderError(token); } if (entry.scope === "transient") { return entry.produce(scope) as T; } const cache = entry.scope === "request" ? (scope ?? this) : entry.owner; const cached = cache.instances.get(token); if (cached !== undefined) return cached as T; const value = entry.produce(scope); cache.instances.set(token, value); return value as T; } /** Resolves a token, awaits async providers. */ async resolveAsync(token: Token, scope?: Container): Promise { const entry = this.findEntry(token); if (!entry) { const fallback = this.lookupFallback(token, scope); if (fallback !== undefined) return (await fallback) as T; throw new UnknownProviderError(token); } if (entry.scope === "transient") { const value = entry.produceAsync ? await entry.produceAsync(scope) : await entry.produce(scope); return value as T; } const cache = entry.scope === "request" ? (scope ?? this) : entry.owner; const cached = cache.instances.get(token); if (cached !== undefined) return (await cached) as T; const value = entry.produceAsync ? await entry.produceAsync(scope) : await entry.produce(scope); cache.instances.set(token, value); return value as T; } private findEntry(token: Token): FactoryEntry | undefined { const own = this.factories.get(token); if (own) return own; return this.parent?.findEntry(token); } private lookupFallback(token: Token, scope?: Container): unknown { const fallback = this.fallbackResolver ?? this.parent?.fallbackResolver; if (!fallback) return undefined; return fallback(token, scope); } /** Registers a class, resolving its constructor dependencies automatically. */ registerClass( ctor: AbstractType, options: { scope?: ProviderScope; inject?: Token[] } = {}, ): void { this.set(ctor as Token, { scope: options.scope ?? "singleton", produce: (scope) => this.instantiate(ctor, options.inject, scope), produceAsync: async (scope) => this.instantiateAsync(ctor, options.inject, scope), }); } /** Registers a plain value. */ registerValue(token: Token, value: T): void { this.set(token as Token, { scope: "singleton", produce: () => value }); } /** Registers a factory function. */ registerFactory( token: Token, factory: (...dependencies: unknown[]) => T | Promise, options: { inject?: Token[]; scope?: ProviderScope } = {}, ): void { this.set(token as Token, { scope: options.scope ?? "singleton", produce: (scope) => { const dependencies = (options.inject ?? []).map((dependency) => this.resolve(dependency, scope), ); return factory(...dependencies); }, produceAsync: async (scope) => { const dependencies = await Promise.all( (options.inject ?? []).map((dependency) => this.resolveAsync(dependency, scope), ), ); return factory(...dependencies); }, }); } /** Registers a provider definition. */ registerProvider(provider: ProviderDefinition): void { if (provider.useValue !== undefined) { this.registerValue( (provider.provide ?? provider.useClass) as Token, provider.useValue, ); return; } if (provider.useFactory) { const token = provider.provide ?? provider.useFactory; this.registerFactory(token as Token, provider.useFactory, { inject: provider.inject, scope: provider.scope, }); return; } if (provider.useClass) { this.registerClass(provider.useClass, { scope: provider.scope, inject: provider.inject, }); return; } throw new UnknownProviderError(provider.provide); } /** Instantiates a class, resolving constructor parameters by type. */ instantiate( ctor: AbstractType, inject?: Token[], scope?: Container, ): T { const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ?? []) as unknown[]; const overrides = getInjectTokens(ctor as object); const optional = getOptionalParams(ctor as object); const dependencies: unknown[] = []; const length = Math.max( paramTypes.length, overrides.size, inject?.length ?? 0, ); for (let index = 0; index < length; index++) { const token = overrides.get(index) ?? inject?.[index] ?? (paramTypes[index] as Token | undefined); if (token === undefined || token === Object || token === Function) { if (optional.has(index)) { dependencies.push(undefined); continue; } throw new UnknownProviderError( `${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`, ); } dependencies.push(this.resolve(token, scope)); } return new (ctor as unknown as new (...args: unknown[]) => T)( ...dependencies, ); } /** Instantiates a class and awaits asynchronous dependencies. */ async instantiateAsync( ctor: AbstractType, inject?: Token[], scope?: Container, ): Promise { const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ?? []) as unknown[]; const overrides = getInjectTokens(ctor as object); const optional = getOptionalParams(ctor as object); const dependencies: unknown[] = []; const length = Math.max( paramTypes.length, overrides.size, inject?.length ?? 0, ); for (let index = 0; index < length; index++) { const token = overrides.get(index) ?? inject?.[index] ?? (paramTypes[index] as Token | undefined); if (token === undefined || token === Object || token === Function) { if (optional.has(index)) { dependencies.push(undefined); continue; } throw new UnknownProviderError( `${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`, ); } dependencies.push(await this.resolveAsync(token, scope)); } return new (ctor as unknown as new (...args: unknown[]) => T)( ...dependencies, ); } }