@furystack/inject
Version:
Dependency Injection framework for FuryStack
383 lines • 14.2 kB
JavaScript
/**
* 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