UNPKG

@furystack/inject

Version:

Dependency Injection framework for FuryStack

383 lines 14.2 kB
/** * 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 { path; constructor(path) { super(`Circular dependency detected: ${path.join(' -> ')}`); this.path = path; 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 { parentName; parentLifetime; childName; childLifetime; constructor(parentName, parentLifetime, childName, childLifetime) { super(`Service '${parentName}' (${parentLifetime}) cannot depend on '${childName}' (${childLifetime}): ${parentLifetime} factories may only depend on compatible lifetimes.`); this.parentName = parentName; this.parentLifetime = parentLifetime; this.childName = childName; this.childLifetime = childLifetime; 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 { tokenName; constructor(tokenName) { super(`Service '${tokenName}' is async. Resolve it via injector.getAsync() instead of injector.get().`); this.tokenName = tokenName; this.name = 'AsyncTokenInSyncContextError'; } } const isParentCompatible = (parent, child) => { 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 { parent; owner; cache = new Map(); bindings = new Map(); disposeCallbacks = []; isDisposed = false; constructor(options) { this.parent = options?.parent ?? null; this.owner = options?.owner; } ensureLive() { if (this.isDisposed) { throw new InjectorDisposedError(); } } rootInjector() { // eslint-disable-next-line @typescript-eslint/no-this-alias let current = this; while (current.parent) { current = current.parent; } return current; } ownerForLifetime(lifetime) { switch (lifetime) { case 'singleton': return this.rootInjector(); case 'scoped': case 'transient': return this; default: return this; } } findCached(token) { // 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; } findFactory(token) { const owning = this.ownerForLifetime(token.lifetime); const bound = owning.bindings.get(token.id); if (bound) { return bound; } return token.factory; } buildContext(token, owningInjector, resolving) { const { lifetime, name: parentName } = token; const inject = (depToken) => { if (!isParentCompatible(lifetime, depToken.lifetime)) { throw new InvalidLifetimeDependencyError(parentName, lifetime, depToken.name, depToken.lifetime); } return owningInjector.resolveSync(depToken, resolving); }; const injectAsync = (depToken) => { if (!isParentCompatible(lifetime, depToken.lifetime)) { throw new InvalidLifetimeDependencyError(parentName, lifetime, depToken.name, depToken.lifetime); } return owningInjector.resolveAsync(depToken, resolving); }; const onDispose = (cb) => { 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. */ get(token) { this.ensureLive(); if (token.isAsync) { throw new AsyncTokenInSyncContextError(token.name); } return this.resolveSync(token, new Set()); } /** * 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. */ getAsync(token) { try { this.ensureLive(); if (!token.isAsync) { return Promise.resolve(this.resolveSync(token, new Set())); } return this.resolveAsync(token, new Set()); } 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); } } resolveSync(token, resolving) { const existing = this.findCached(token); if (existing) { return this.consumeCached(existing.entry, token); } const owning = this.ownerForLifetime(token.lifetime); if (token.lifetime !== 'transient' && owning !== this) { return owning.resolveSync(token, resolving); } return owning.instantiateSync(token, resolving); } resolveAsync(token, resolving) { // 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(existing.entry, token); } const owning = this.ownerForLifetime(token.lifetime); if (token.lifetime !== 'transient' && owning !== this) { return owning.resolveAsync(token, resolving); } return owning.instantiateAsync(token, resolving); } consumeCached(entry, token) { if (entry.status === 'resolved') { return entry.value; } if (entry.status === 'failed') { throw entry.error; } throw new AsyncTokenInSyncContextError(token.name); } async consumeCachedAsync(entry, _token) { if (entry.status === 'resolved') { return entry.value; } if (entry.status === 'failed') { throw entry.error; } return entry.promise; } pushResolving(resolving, token) { 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); } popResolving(resolving, token) { resolving.delete(token.id); } instantiateSync(token, resolving) { this.pushResolving(resolving, token); const ctx = this.buildContext(token, this, resolving); const factory = this.findFactory(token); 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); } } instantiateAsync(token, resolving) { this.pushResolving(resolving, token); const ctx = this.buildContext(token, this, resolving); const factory = this.findFactory(token); let promise; 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) => { this.popResolving(resolving, token); if (token.lifetime !== 'transient') { this.cache.set(token.id, { status: 'failed', error }); } throw error; }); } bind(token, factory) { 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. */ invalidate(token) { 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. */ isResolved(token) { 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. */ createScope(options) { 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`. */ async [Symbol.asyncDispose]() { if (this.isDisposed) { return; } this.isDisposed = true; const callbacks = this.disposeCallbacks.splice(0).reverse(); const errors = []; 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 = () => 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 (parent, fn, options) => { const scope = parent.createScope(options); try { return await fn(scope); } finally { await scope[Symbol.asyncDispose](); } }; //# sourceMappingURL=injector.js.map