@furystack/inject
Version:
Dependency Injection framework for FuryStack
455 lines (414 loc) • 16.5 kB
text/typescript
import type {
AnyToken,
AsyncServiceFactory,
AsyncToken,
CreateScopeOptions,
DisposeCallback,
Lifetime,
ServiceContext,
ServiceFactory,
SyncToken,
} from './types.js'
/**
* Thrown when a method is called on an injector that has already been disposed.
*/
export class InjectorDisposedError extends Error {
constructor() {
super('Injector already disposed')
this.name = 'InjectorDisposedError'
}
}
/**
* Thrown when a factory depends on itself (directly or transitively) during
* instantiation.
*/
export class CircularDependencyError extends Error {
constructor(public readonly path: readonly string[]) {
super(`Circular dependency detected: ${path.join(' -> ')}`)
this.name = 'CircularDependencyError'
}
}
/**
* Thrown when a singleton-lifetime service attempts to depend on a non-singleton
* service, or a scoped service attempts to depend on a transient.
*/
export class InvalidLifetimeDependencyError extends Error {
constructor(
public readonly parentName: string,
public readonly parentLifetime: Lifetime,
public readonly childName: string,
public readonly childLifetime: Lifetime,
) {
super(
`Service '${parentName}' (${parentLifetime}) cannot depend on '${childName}' (${childLifetime}): ${parentLifetime} factories may only depend on compatible lifetimes.`,
)
this.name = 'InvalidLifetimeDependencyError'
}
}
/**
* Thrown when attempting to resolve an async token via the synchronous
* {@link Injector.get} method. In well-typed call sites this case is caught at
* compile time by {@link Injector.get}'s signature; the runtime check exists
* as a defense for dynamically-constructed tokens.
*/
export class AsyncTokenInSyncContextError extends Error {
constructor(public readonly tokenName: string) {
super(`Service '${tokenName}' is async. Resolve it via injector.getAsync() instead of injector.get().`)
this.name = 'AsyncTokenInSyncContextError'
}
}
type CacheEntry =
| { status: 'resolved'; value: unknown }
| { status: 'pending'; promise: Promise<unknown> }
| { status: 'failed'; error: unknown }
type AnyFactory<TService> = ServiceFactory<TService> | AsyncServiceFactory<TService>
const isParentCompatible = (parent: Lifetime, child: Lifetime): boolean => {
switch (parent) {
case 'singleton':
return child === 'singleton'
case 'scoped':
return child === 'singleton' || child === 'scoped'
case 'transient':
return true
default:
return false
}
}
/**
* The dependency injection container. Created via {@link createInjector} or
* {@link Injector.createScope}. Manages service resolution, caching, and
* disposal across a hierarchical scope tree.
*/
export class Injector implements AsyncDisposable {
public readonly parent: Injector | null
public readonly owner: unknown
private readonly cache = new Map<symbol, CacheEntry>()
private readonly bindings = new Map<symbol, AnyFactory<unknown>>()
private readonly disposeCallbacks: DisposeCallback[] = []
private isDisposed = false
constructor(options?: { parent?: Injector; owner?: unknown }) {
this.parent = options?.parent ?? null
this.owner = options?.owner
}
private ensureLive(): void {
if (this.isDisposed) {
throw new InjectorDisposedError()
}
}
private rootInjector(): Injector {
// eslint-disable-next-line @typescript-eslint/no-this-alias
let current: Injector = this
while (current.parent) {
current = current.parent
}
return current
}
private ownerForLifetime(lifetime: Lifetime): Injector {
switch (lifetime) {
case 'singleton':
return this.rootInjector()
case 'scoped':
case 'transient':
return this
default:
return this
}
}
private findCached(token: AnyToken<unknown>): { injector: Injector; entry: CacheEntry } | null {
// Cache is owned by the lifetime resolver: singletons live at the root,
// scoped tokens live at the requesting scope. Walking every ancestor
// would surface a cached `null` from an ancestor that resolved a scoped
// token with its default factory -- masking any descendant `bind()`
// that rebinds the same token on a child scope.
const owning = this.ownerForLifetime(token.lifetime)
const entry = owning.cache.get(token.id)
return entry ? { injector: owning, entry } : null
}
private findFactory<TService>(token: AnyToken<TService>): AnyFactory<TService> {
const owning = this.ownerForLifetime(token.lifetime)
const bound = owning.bindings.get(token.id)
if (bound) {
return bound as AnyFactory<TService>
}
return token.factory
}
private buildContext(token: AnyToken<unknown>, owningInjector: Injector, resolving: Set<symbol>): ServiceContext {
const { lifetime, name: parentName } = token
const inject = <TService>(depToken: SyncToken<TService>): TService => {
if (!isParentCompatible(lifetime, depToken.lifetime)) {
throw new InvalidLifetimeDependencyError(parentName, lifetime, depToken.name, depToken.lifetime)
}
return owningInjector.resolveSync<TService>(depToken, resolving)
}
const injectAsync = <TService>(depToken: AnyToken<TService>): Promise<TService> => {
if (!isParentCompatible(lifetime, depToken.lifetime)) {
throw new InvalidLifetimeDependencyError(parentName, lifetime, depToken.name, depToken.lifetime)
}
return owningInjector.resolveAsync<TService>(depToken, resolving)
}
const onDispose = (cb: DisposeCallback): void => {
owningInjector.disposeCallbacks.push(cb)
}
return {
inject,
injectAsync,
injector: owningInjector,
token,
onDispose,
}
}
/**
* Resolves a sync token. Throws {@link InjectorDisposedError} if the
* injector is already disposed and {@link AsyncTokenInSyncContextError} if
* a runtime-async token slips past the compile-time check.
*/
public get<TService>(token: SyncToken<TService>): TService {
this.ensureLive()
if (token.isAsync) {
throw new AsyncTokenInSyncContextError(token.name)
}
return this.resolveSync<TService>(token, new Set<symbol>())
}
/**
* Resolves a sync or async token. Sync tokens are wrapped in a resolved
* promise. Synchronous failures (disposed injector, sync-throwing async
* factory) are normalised to a rejected promise so callers can rely on
* `.rejects` / `.catch` uniformly.
*/
public getAsync<TService>(token: AnyToken<TService>): Promise<TService> {
try {
this.ensureLive()
if (!token.isAsync) {
return Promise.resolve(this.resolveSync<TService>(token, new Set<symbol>()))
}
return this.resolveAsync<TService>(token, new Set<symbol>())
} catch (error) {
// Normalise synchronous errors (e.g. a disposed injector or an async
// factory that sync-throws before returning a promise) into a rejected
// promise so callers can always rely on `.rejects` / `.catch`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- needed for `prefer-promise-reject-errors`; `error` is `unknown` from catch
return Promise.reject(error as Error)
}
}
private resolveSync<TService>(token: SyncToken<TService>, resolving: Set<symbol>): TService {
const existing = this.findCached(token)
if (existing) {
return this.consumeCached<TService>(existing.entry, token)
}
const owning = this.ownerForLifetime(token.lifetime)
if (token.lifetime !== 'transient' && owning !== this) {
return owning.resolveSync<TService>(token, resolving)
}
return owning.instantiateSync<TService>(token, resolving)
}
private resolveAsync<TService>(token: AnyToken<TService>, resolving: Set<symbol>): Promise<TService> {
// Cycle check BEFORE the cache lookup: if the current resolution chain
// is already waiting on this token, a cached `pending` entry would just
// return its own in-flight promise and deadlock. Raising the cycle
// error here surfaces the real problem instead.
if (resolving.has(token.id)) {
const path = [...Array.from(resolving).map((id) => id.description ?? '<anonymous>'), token.name]
return Promise.reject(new CircularDependencyError(path))
}
const existing = this.findCached(token)
if (existing) {
return this.consumeCachedAsync<TService>(existing.entry, token)
}
const owning = this.ownerForLifetime(token.lifetime)
if (token.lifetime !== 'transient' && owning !== this) {
return owning.resolveAsync<TService>(token, resolving)
}
return owning.instantiateAsync<TService>(token, resolving)
}
private consumeCached<TService>(entry: CacheEntry, token: AnyToken<TService>): TService {
if (entry.status === 'resolved') {
return entry.value as TService
}
if (entry.status === 'failed') {
throw entry.error
}
throw new AsyncTokenInSyncContextError(token.name)
}
private async consumeCachedAsync<TService>(entry: CacheEntry, _token: AnyToken<TService>): Promise<TService> {
if (entry.status === 'resolved') {
return entry.value as TService
}
if (entry.status === 'failed') {
throw entry.error
}
return entry.promise as Promise<TService>
}
private pushResolving(resolving: Set<symbol>, token: AnyToken<unknown>): void {
if (resolving.has(token.id)) {
const path = [...Array.from(resolving).map((id) => id.description ?? '<anonymous>'), token.name]
throw new CircularDependencyError(path)
}
resolving.add(token.id)
}
private popResolving(resolving: Set<symbol>, token: AnyToken<unknown>): void {
resolving.delete(token.id)
}
private instantiateSync<TService>(token: SyncToken<TService>, resolving: Set<symbol>): TService {
this.pushResolving(resolving, token)
const ctx = this.buildContext(token, this, resolving)
const factory = this.findFactory<TService>(token) as ServiceFactory<TService>
try {
const value = factory(ctx)
if (token.lifetime !== 'transient') {
this.cache.set(token.id, { status: 'resolved', value })
}
return value
} catch (error) {
if (token.lifetime !== 'transient') {
this.cache.set(token.id, { status: 'failed', error })
}
throw error
} finally {
this.popResolving(resolving, token)
}
}
private instantiateAsync<TService>(token: AnyToken<TService>, resolving: Set<symbol>): Promise<TService> {
this.pushResolving(resolving, token)
const ctx = this.buildContext(token, this, resolving)
const factory = this.findFactory<TService>(token) as AsyncServiceFactory<TService>
let promise: Promise<TService>
try {
promise = factory(ctx)
} catch (error) {
this.popResolving(resolving, token)
if (token.lifetime !== 'transient') {
this.cache.set(token.id, { status: 'failed', error })
}
throw error
}
if (token.lifetime !== 'transient') {
this.cache.set(token.id, { status: 'pending', promise })
}
// Keep the token in `resolving` until the promise settles so that cycles
// formed across async boundaries (A awaits B awaits A) are caught by
// `pushResolving` on re-entry instead of deadlocking on a pending cache entry.
return promise.then(
(value) => {
this.popResolving(resolving, token)
if (token.lifetime !== 'transient') {
this.cache.set(token.id, { status: 'resolved', value })
}
return value
},
(error: unknown) => {
this.popResolving(resolving, token)
if (token.lifetime !== 'transient') {
this.cache.set(token.id, { status: 'failed', error })
}
throw error
},
)
}
/**
* Installs a factory override for `token` on the injector that would own its
* cached instance (root for singleton, this injector for scoped/transient).
* Any cached entry for the token on that injector is dropped so the next
* resolution uses the new factory.
*
* Scope caveat: a `scoped` bind applies only to the injector it was called
* on. Descendant scopes each own their own cache and resolve scoped tokens
* against `token.factory` unless they also call `bind`. Bind at the highest
* scope whose cache you want the override to populate — usually the same
* scope that will call `injector.get(token)`.
*/
public bind<TService, TLifetime extends Lifetime>(
token: SyncToken<TService, TLifetime>,
factory: ServiceFactory<TService>,
): void
public bind<TService, TLifetime extends Lifetime>(
token: AsyncToken<TService, TLifetime>,
factory: AsyncServiceFactory<TService>,
): void
public bind<TService>(token: AnyToken<TService>, factory: AnyFactory<TService>): void {
this.ensureLive()
const owning = this.ownerForLifetime(token.lifetime)
owning.bindings.set(token.id, factory)
owning.cache.delete(token.id)
}
/**
* Drops any cached entry for `token` on the injector that owns its cached
* instance. The next resolution will run the factory again. Useful for
* recovering from cached factory failures or resetting state between tests.
*/
public invalidate<TService>(token: AnyToken<TService>): void {
this.ensureLive()
const owning = this.ownerForLifetime(token.lifetime)
owning.cache.delete(token.id)
}
/**
* Returns `true` when `token` has a cache entry (resolved, pending or
* failed) on the scope that owns its lifetime -- the root injector for
* singletons, the requesting injector for scoped tokens. Transient
* tokens are never cached and therefore always report `false`.
*
* Useful for bootstrap helpers that must run before a service is first
* resolved: checking `isResolved` lets them fail loudly instead of
* silently leaking the previous instance.
*/
public isResolved<TService>(token: AnyToken<TService>): boolean {
this.ensureLive()
return this.findCached(token) !== null
}
/**
* Creates a child injector. The child has its own cache and bindings;
* singleton resolution still walks up to the root. Disposing the child
* leaves the parent untouched. Disposing the parent disposes all
* descendants reachable through stored references.
*/
public createScope(options?: CreateScopeOptions): Injector {
this.ensureLive()
return new Injector({ parent: this, owner: options?.owner })
}
/**
* Disposes the injector: runs registered `onDispose` callbacks in LIFO
* order, clears the cache, and marks the injector as disposed. Idempotent —
* a second call is a no-op so `await using` and manual teardown paths
* don't have to guard. Errors from callbacks are collected and re-thrown
* as a single `AggregateError`.
*/
public async [Symbol.asyncDispose](): Promise<void> {
if (this.isDisposed) {
return
}
this.isDisposed = true
const callbacks = this.disposeCallbacks.splice(0).reverse()
const errors: unknown[] = []
for (const cb of callbacks) {
try {
await cb()
} catch (error) {
errors.push(error)
}
}
this.cache.clear()
this.bindings.clear()
if (errors.length > 0) {
throw new AggregateError(errors, `Errors thrown during injector disposal (${errors.length})`)
}
}
}
/**
* Creates a new root {@link Injector}.
*/
export const createInjector = (): Injector => new Injector()
/**
* Creates a child scope of `parent`, runs `fn` with it, then disposes the
* scope — including when `fn` throws. Returns `fn`'s resolved value.
*/
export const withScope = async <TResult>(
parent: Injector,
fn: (scope: Injector) => Promise<TResult> | TResult,
options?: CreateScopeOptions,
): Promise<TResult> => {
const scope = parent.createScope(options)
try {
return await fn(scope)
} finally {
await scope[Symbol.asyncDispose]()
}
}