proxydi
Version:
A typed hierarchical DI container that resolves circular dependencies via Proxy
797 lines (783 loc) • 30.2 kB
JavaScript
;
const injectableClasses = {};
/**
* Registers a class as an automatically injectable for dependency injection container.
*
* @param dependencyId - Optional dependency identifier. If omitted, the class name is used.
* @returns A class decorator function.
*
* Note: During dependency resolution, any container that does not have an instance for the specified dependency identifier
* will create an instance of the decorated class. However, if a container already has an instance with that identifier
* prior to resolution, the decorated class will be ignored by that container.
*/
function injectable(dependencyId) {
return function (value, context) {
if ((context === null || context === void 0 ? void 0 : context.kind) !== 'class') {
throw new Error('@injectable decorator should decorate classes');
}
const name = dependencyId ? dependencyId : context.name;
if (injectableClasses[name]) {
throw new Error(`ProxyDi has already regisered dependency ${String(name)} by @injectable`);
}
injectableClasses[name] = value;
};
}
function findInjectableId(injectable) {
// Search in string keys
for (const [id, DependencyClass] of Object.entries(injectableClasses)) {
if (DependencyClass === injectable) {
return id;
}
}
// Search in symbol keys
for (const id of Object.getOwnPropertySymbols(injectableClasses)) {
if (injectableClasses[id] === injectable) {
return id;
}
}
throw new Error(`Class is not @injectable: ${injectable.name}`);
}
exports.ResolveScope = void 0;
(function (ResolveScope) {
ResolveScope[ResolveScope["Parent"] = 1] = "Parent";
ResolveScope[ResolveScope["Current"] = 2] = "Current";
ResolveScope[ResolveScope["Children"] = 4] = "Children";
ResolveScope[ResolveScope["All"] = 7] = "All";
})(exports.ResolveScope || (exports.ResolveScope = {}));
/**
* Type guard to check if injection is AllInjection
*/
function isAllInjection(injection) {
return 'isAll' in injection && injection.isAll === true;
}
const INJECTIONS = Symbol('injections');
/**
* This symbol constant defines a property name.
* This property is present in each dependency instance that was registered in ProxyDiContainer.
* The property stores the dependency identifier that should be used to resolve dependency from the container where it was registered.
*/
const DEPENDENCY_ID = Symbol('DependencyId');
/**
* This symbol constant defines a property name.
* This property is present in each dependency instance that was registered in ProxyDiContainer.
* The property stores a reference to the ProxyDiContainer in which the dependency was registered.
*/
const PROXYDI_CONTAINER = Symbol('proxyDiContainer');
const ON_CONTAINERIZED = Symbol('onContainerized');
const IS_INJECTION_PROXY = Symbol('isInjectionProxy');
const INJECTION_OWNER = Symbol('injectionOwner');
const IS_INSTANCE_PROXY = Symbol('isInstanceProxy');
/**
* Registers an injection for dependency injection.
*
* @param dependencyId - Optional dependecy identifier. If omitted, the property name is used.
* @returns A decorator function for class fields.
*
* The decorated field will receive its dependency from the same container as the injection owner.
*/
const inject = (dependencyId) => {
return function (_value, context) {
if ((context === null || context === void 0 ? void 0 : context.kind) === 'field') {
let id;
if (!dependencyId) {
id = context.name;
}
else if (typeof dependencyId === 'function') {
try {
// Try to find in @injectable (for custom IDs)
id = findInjectableId(dependencyId);
}
catch (_a) {
// Fallback to class.name for non-injectable classes
if (dependencyId.name) {
id = dependencyId.name;
}
else {
throw new Error('Invalid dependency class');
}
}
}
else {
id = dependencyId;
}
const injection = {
property: context.name,
dependencyId: id,
set: context.access.set,
};
context.addInitializer(function () {
if (!this[INJECTIONS]) {
this[INJECTIONS] = {};
}
this[INJECTIONS][injection.property] = injection;
});
}
else {
throw new Error('@inject decorator should decorate fields');
}
};
};
/**
* Registers an injection for multiple dependencies of the same type.
*
* @param dependencyId - Dependency identifier to resolve all instances from container hierarchy.
* @param scope - Bitwise enum to control where to search (Parent | Current | Children). Defaults to Children.
* @returns A decorator function for class fields.
*
* The decorated field will receive an array of all dependencies with the given ID
* from the container hierarchy according to the scope parameter.
*/
const injectAll = (dependencyId, scope = exports.ResolveScope.Children) => {
return function (_value, context) {
if ((context === null || context === void 0 ? void 0 : context.kind) === 'field') {
let id;
if (typeof dependencyId === 'function') {
try {
// Try to find in @injectable (for custom IDs)
id = findInjectableId(dependencyId);
}
catch (_a) {
// Fallback to class.name for non-injectable classes
if (dependencyId.name) {
id = dependencyId.name;
}
else {
throw new Error('Invalid dependency class');
}
}
}
else {
id = dependencyId;
}
const injection = {
property: context.name,
dependencyId: id,
set: context.access.set,
isAll: true,
scope: scope,
};
context.addInitializer(function () {
if (!this[INJECTIONS]) {
this[INJECTIONS] = {};
}
this[INJECTIONS][injection.property] = injection;
});
}
else {
throw new Error('@injectAll decorator should decorate fields');
}
};
};
const DEFAULT_SETTINGS = {
allowRewriteDependencies: false,
resolveInContainerContext: false,
};
var _a;
class InjectionProxy {
constructor(onwer, container) {
this[_a] = true;
this[INJECTION_OWNER] = onwer;
this[PROXYDI_CONTAINER] = container;
}
}
_a = IS_INJECTION_PROXY;
const makeInjectionProxy = (injection, injectionOwner, container) => {
function getDependency() {
if (container.isKnown(injection.dependencyId)) {
const dependency = container.resolve(injection.dependencyId);
if (!container.settings.allowRewriteDependencies) {
injection.set(injectionOwner, dependency);
}
return dependency;
}
else {
throw new Error(`Unknown dependency: ${String(injection.dependencyId)}`);
}
}
return new Proxy(new InjectionProxy(injectionOwner, container), {
get: function (target, prop, receiver) {
if (target[prop]) {
return target[prop];
}
const dependency = getDependency();
return Reflect.get(dependency, prop, receiver);
},
set: function (target, prop, value) {
const dependency = getDependency();
return Reflect.set(dependency, prop, value);
},
has: function (target, prop) {
const dependency = getDependency();
return Reflect.has(dependency, prop);
},
});
};
const makeInjectAllProxy = (injection, injectionOwner, container) => {
function getDependencies() {
const ownerContainer = injectionOwner[PROXYDI_CONTAINER];
if (!ownerContainer) {
throw new Error('Instance is not registered in any container');
}
// Use existing resolveAll function with scope from injection
return ownerContainer.resolveAll(injection.dependencyId, injection.scope);
}
return new Proxy(new InjectionProxy(injectionOwner, container), {
get: function (target, prop, receiver) {
if (target[prop]) {
return target[prop];
}
const dependencies = getDependencies();
return Reflect.get(dependencies, prop, receiver);
},
set: function (target, prop, value) {
const dependencies = getDependencies();
return Reflect.set(dependencies, prop, value);
},
has: function (target, prop) {
const dependencies = getDependencies();
return Reflect.has(dependencies, prop);
},
});
};
function makeDependencyProxy(dependency) {
const injectionValues = {};
return new Proxy(dependency, {
get: function (target, prop, receiver) {
if (prop === IS_INSTANCE_PROXY) {
return true;
}
if (injectionValues[prop]) {
return injectionValues[prop];
}
return Reflect.get(target, prop, receiver);
},
set: function (target, prop, value) {
injectionValues[prop] = value;
return Reflect.set(target, prop, value);
},
});
}
const middlewaresClasses = {};
function middleware() {
return function (value, context) {
if ((context === null || context === void 0 ? void 0 : context.kind) !== 'class') {
throw new Error('@middleware decorator should decorate classes');
}
const name = context.name;
if (middlewaresClasses[name]) {
throw new Error(`ProxyDi has already regisered middleware ${String(name)} by @middleware`);
}
middlewaresClasses[name] = value;
};
}
class MiddlewareManager {
constructor(parent) {
this.parent = parent;
this.handlers = {
register: [],
remove: [],
resolve: [],
};
}
add(middleware) {
if (isRegistrator(middleware)) {
middleware.onRegister && this.on('register', middleware.onRegister);
}
if (isRemover(middleware)) {
middleware.onRemove && this.on('remove', middleware.onRemove);
}
if (isResolver(middleware)) {
middleware.onResolve && this.on('resolve', middleware.onResolve);
}
}
remove(middleware) {
if (isRegistrator(middleware)) {
middleware.onRegister &&
this.off('register', middleware.onRegister);
}
if (isRemover(middleware)) {
middleware.onRemove && this.off('remove', middleware.onRemove);
}
if (isResolver(middleware)) {
middleware.onResolve && this.off('resolve', middleware.onResolve);
}
}
on(event, listener) {
this.handlers[event].push(listener);
}
onRegister(context) {
var _a;
this.handlers.register.forEach((listener) => listener(context));
(_a = this.parent) === null || _a === void 0 ? void 0 : _a.onRegister(context);
}
onRemove(context) {
var _a;
this.handlers.remove.forEach((listener) => listener(context));
(_a = this.parent) === null || _a === void 0 ? void 0 : _a.onRemove(context);
}
onResolve(context) {
let result = context;
this.handlers.resolve.forEach((listener) => {
result = listener(result);
});
return result;
}
off(event, listener) {
const index = this.handlers[event].indexOf(listener);
if (index !== -1) {
this.handlers[event].splice(index, 1);
}
}
}
function isRegistrator(middleware) {
return !!middleware.onRegister;
}
function isRemover(middleware) {
return !!middleware.onRemove;
}
function isResolver(middleware) {
return !!middleware.onResolve;
}
/**
* A dependency injection container
*/
class ProxyDiContainer {
/**
* Creates a new instance of ProxyDiContainer.
* @param settings Optional container settings to override defaults.
* @param parent Optional parent container.
*/
constructor(settings, parent) {
this._children = {};
/**
* Holds dependency instances registered particular in this container.
*/
this.dependencies = {};
/**
* Holds proxies for dependencies registered in parent containers to provide for it dependencies from this container
*/
this.inContextProxies = {};
this.resolveImpl = (dependencyId, scope = exports.ResolveScope.Current | exports.ResolveScope.Parent) => {
// Current - check inContextProxies and dependencies
if (scope & exports.ResolveScope.Current) {
const proxy = this.inContextProxies[dependencyId];
if (proxy) {
return proxy;
}
const instance = this.dependencies[dependencyId];
if (instance) {
return instance;
}
}
// Parent - recursively check up the hierarchy
if (scope & exports.ResolveScope.Parent) {
if (this.parent) {
const parentInstance = this.parent.findDependency(dependencyId);
if (parentInstance) {
if (parentInstance[PROXYDI_CONTAINER] !== this &&
typeof parentInstance === 'object' &&
this.settings.resolveInContainerContext) {
const proxy = makeDependencyProxy(parentInstance);
this.injectDependenciesTo(proxy);
this.inContextProxies[dependencyId] = proxy;
return proxy;
}
return parentInstance;
}
}
}
// Children - recursively check down the hierarchy
if (scope & exports.ResolveScope.Children) {
for (const child of this.children) {
if (child.hasOwn(dependencyId)) {
return child.resolve(dependencyId, exports.ResolveScope.Current);
}
if (child.isKnown(dependencyId, exports.ResolveScope.Children)) {
return child.resolve(dependencyId, exports.ResolveScope.Children);
}
}
}
// @injectable - create instance (always reached if isKnown returned true)
const InjectableClass = injectableClasses[dependencyId];
return this.register(InjectableClass, dependencyId);
};
this.id = ProxyDiContainer.idCounter++;
this.middlewareManager = new MiddlewareManager(parent === null || parent === void 0 ? void 0 : parent.middlewareManager);
if (parent) {
this.parent = parent;
this.parent.addChild(this);
}
this.settings = Object.assign(Object.assign({}, DEFAULT_SETTINGS), settings);
}
registerMiddleware(middleware) {
this.middlewareManager.add(middleware);
}
removeMiddleware(middleware) {
this.middlewareManager.remove(middleware);
}
register(dependency, dependecyId) {
var _a, _b;
let id;
if (dependecyId) {
id = this.normalizeDependencyId(dependecyId);
}
else if (typeof dependency === 'function') {
try {
id = findInjectableId(dependency);
}
catch (_c) {
id = dependency.name;
}
}
else if (((_a = dependency === null || dependency === void 0 ? void 0 : dependency.constructor) === null || _a === void 0 ? void 0 : _a.name) &&
dependency.constructor.name !== 'Object') {
id = dependency.constructor.name;
}
else {
throw new Error('dependencyId is required when registering plain objects or literals');
}
if (this.dependencies[id]) {
if (!this.settings.allowRewriteDependencies) {
throw new Error(`ProxyDi already has dependency for ${String(id)}`);
}
}
let instance;
const isClass = typeof dependency === 'function';
if (isClass) {
instance = new dependency();
}
else {
instance = dependency;
}
const isObject = typeof instance === 'object';
if (isObject) {
instance[PROXYDI_CONTAINER] = this;
instance[DEPENDENCY_ID] = id;
instance[ON_CONTAINERIZED] && instance[ON_CONTAINERIZED](this);
}
this.injectDependenciesTo(instance);
this.dependencies[id] = instance;
const constructorName = (_b = instance.constructor) === null || _b === void 0 ? void 0 : _b.name;
if (constructorName && middlewaresClasses[constructorName]) {
this.middlewareManager.add(instance);
}
let context = {
container: this,
dependencyId: id,
dependency: instance,
};
this.middlewareManager.onRegister(context);
return instance;
}
/**
* Checks if a dependency with the given ID is known to the container based on the scope.
* @param dependencyId The identifier of the dependency. Can be a string, symbol, or class constructor (which will be normalized to class name).
* @param scope Bitwise enum to control where to search. Defaults to Current | Parent (searches up the hierarchy).
* @returns True if the dependency is known, false otherwise.
*/
isKnown(dependencyId, scope = exports.ResolveScope.Current | exports.ResolveScope.Parent) {
const id = this.normalizeDependencyId(dependencyId);
// @injectable classes are always available
if (injectableClasses[id]) {
return true;
}
// Current - check in this container
if (scope & exports.ResolveScope.Current) {
if (this.inContextProxies[id] || this.dependencies[id]) {
return true;
}
}
// Parent - recursively check up the hierarchy
if (scope & exports.ResolveScope.Parent) {
if (this.parent && this.parent.isKnown(id, exports.ResolveScope.Current | exports.ResolveScope.Parent)) {
return true;
}
}
// Children - recursively check down the hierarchy
if (scope & exports.ResolveScope.Children) {
for (const child of this.children) {
if (child.isKnown(id, exports.ResolveScope.Current | exports.ResolveScope.Children)) {
return true;
}
}
}
return false;
}
/**
* Checks if a dependency with the given ID exists in this container only (does not check parents)
* @param dependencyId The identifier of the dependency. Can be a string, symbol, or class constructor (which will be normalized to class name).
* @returns True if the dependency exists in this container, false otherwise.
*/
hasOwn(dependencyId) {
const id = this.normalizeDependencyId(dependencyId);
return !!(this.inContextProxies[id] || this.dependencies[id]);
}
resolve(dependency, scope = exports.ResolveScope.Current | exports.ResolveScope.Parent) {
if (typeof dependency === 'function') {
let id;
try {
id = findInjectableId(dependency);
}
catch (_a) {
id = dependency.name;
}
return this.resolve(id, scope);
}
if (!this.isKnown(dependency, scope)) {
throw new Error(`Can't resolve unknown dependency: ${String(dependency)}`);
}
let context = {
container: this,
dependencyId: dependency,
dependency: this.resolveImpl(dependency, scope),
};
context = this.middlewareManager.onResolve(context);
return context.dependency;
}
/**
* Injects dependencies to the given object based on its defined injections metadata. Does not affect the container.
* @param injectionsOwner The object to inject dependencies into.
*/
injectDependenciesTo(injectionsOwner) {
const dependencyInjects = injectionsOwner[INJECTIONS] || {};
Object.values(dependencyInjects).forEach((injection) => {
const dependencyProxy = isAllInjection(injection)
? makeInjectAllProxy(injection, injectionsOwner, this)
: makeInjectionProxy(injection, injectionsOwner, this);
injection.set(injectionsOwner, dependencyProxy);
});
}
/**
* Creates instances for all injectable classes and registers them in this container.
* @returns This container to allow use along with constructor.
*/
registerInjectables() {
for (const [dependencyId, InjectableClass] of Object.entries(injectableClasses)) {
this.register(InjectableClass, dependencyId);
}
return this;
}
/**
* Finalizes dependency injections, prevents further rewriting of dependencies,
* and recursively bakes injections for child containers.
*/
bakeInjections() {
for (const dependency of Object.values(this.dependencies)) {
const dependencyInjects = dependency[INJECTIONS] || {};
Object.values(dependencyInjects).forEach((inject) => {
if (!isAllInjection(inject)) {
// Only bake single injections
// Array injections (@injectAll) remain dynamic - array updates on each access,
// but elements are baked through container.resolve()
const value = this.resolve(inject.dependencyId);
inject.set(dependency, value);
}
});
}
this.settings.allowRewriteDependencies = false;
for (const child of Object.values(this._children)) {
child.bakeInjections();
}
}
/**
* Creates a child container that inherits settings and dependencies from this container.
* @returns A new child instance of ProxyDiContainer.
*/
createChildContainer() {
return new ProxyDiContainer(this.settings, this);
}
/**
* Removes a given dependency from the container using either the dependency instance or its ID.
* @param dependencyOrId The dependency instance or dependency identifier to remove.
*/
remove(dependencyOrId) {
var _a;
const id = isDependency(dependencyOrId)
? dependencyOrId[DEPENDENCY_ID]
: dependencyOrId;
const dependency = this.dependencies[id];
if (dependency) {
const constructorName = (_a = dependency.constructor) === null || _a === void 0 ? void 0 : _a.name;
if (constructorName && middlewaresClasses[constructorName]) {
this.middlewareManager.remove(dependency);
}
const dependencyInjects = dependency[INJECTIONS]
? dependency[INJECTIONS]
: {};
Object.values(dependencyInjects).forEach((inject) => {
inject.set(dependency, undefined);
});
delete dependency[DEPENDENCY_ID];
delete this.dependencies[id];
this.middlewareManager.onRemove({
container: this,
dependencyId: id,
dependency,
});
}
}
/**
* Destroys the container by removing all dependencies,
* recursively destroying child containers and removing itself from its parent.
*/
destroy() {
const allDependencies = Object.values(this.dependencies);
for (const dependency of allDependencies) {
this.remove(dependency);
}
this.dependencies = {};
for (const child of Object.values(this._children)) {
child.destroy();
}
this._children = {};
if (this.parent) {
this.parent.removeChild(this.id);
this.parent = undefined;
}
}
/**
* Recursively finds a dependency by its ID from this container or its parent.
* @param dependencyId The identifier of the dependency to find.
* @returns The dependency if found, otherwise undefined.
*/
findDependency(dependencyId) {
const dependency = this.dependencies[dependencyId];
if (!dependency && this.parent) {
const parentDependency = this.parent.findDependency(dependencyId);
return parentDependency;
}
return dependency;
}
/**
* Normalizes dependency identifier by converting class constructors to their names.
* @param id The dependency identifier (string, symbol, or class constructor).
* @returns Normalized dependency identifier (string or symbol).
*/
normalizeDependencyId(id) {
if (typeof id === 'function') {
try {
return findInjectableId(id);
}
catch (_a) {
return id.name;
}
}
return id;
}
resolveAll(dependencyId, scope = exports.ResolveScope.Children) {
const id = this.normalizeDependencyId(dependencyId);
return this.recursiveResolveAll(id, scope);
}
recursiveResolveAll(dependencyId, scope = exports.ResolveScope.All) {
if (scope === 0) {
throw new Error('ResolveScope must have at least one flag set');
}
let all = [];
// Current - search in current container only
if (scope & exports.ResolveScope.Current) {
if (this.hasOwn(dependencyId)) {
all.push(this.resolve(dependencyId));
}
}
// Parent - search up the hierarchy
if (scope & exports.ResolveScope.Parent) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
let parent = this.parent;
if (parent && parent.isKnown(dependencyId)) {
const dependency = parent.resolve(dependencyId);
all.push(dependency);
}
}
// Children - recursively search down the hierarchy
if (scope & exports.ResolveScope.Children) {
for (const child of this.children) {
// Recursive call with Children + Current (always include Current for children)
const childScope = exports.ResolveScope.Children | exports.ResolveScope.Current;
const childResults = child.recursiveResolveAll(dependencyId, childScope);
all = all.concat(childResults);
}
}
// Remove duplicates - same instance should not appear twice
const unique = [];
const seen = new Set();
for (const item of all) {
if (!seen.has(item)) {
seen.add(item);
unique.push(item);
}
}
return unique;
}
/**
* All direct descendants of this container
*/
get children() {
return Object.values(this._children);
}
/**
*
* @param id Unique identifier of container
* @returns
*/
getChild(id) {
const child = this._children[id];
if (!child) {
throw new Error(`Unknown ProxyDiContainer child ID: ${id}`);
}
return child;
}
/**
* Registers a child container to this container.
* @param child The child container to add.
* @throws Error if a child with the same ID already exists.
*/
addChild(child) {
if (this._children[child.id]) {
throw new Error(`ProxyDi already has child with id ${child.id}`);
}
this._children[child.id] = child;
}
/**
* Removes a child container by its ID.
* @param id The identifier of the child container to remove.
*/
removeChild(id) {
const child = this._children[id];
if (child) {
delete this._children[id];
}
}
}
/**
* Static counter used to assign unique IDs to containers.
*/
ProxyDiContainer.idCounter = 0;
/**
* Helper function to determine if the provided argument is a dependency instance.
* @param dependencyOrId The dependency instance or dependency identifier.
* @returns True if the argument is a dependency instance, false otherwise.
*/
function isDependency(dependencyOrId) {
return (typeof dependencyOrId === 'object' &&
!!dependencyOrId[DEPENDENCY_ID]);
}
function resolveAll(instance, dependencyId, scope = exports.ResolveScope.Children) {
if (typeof dependencyId === 'function') {
const id = findInjectableId(dependencyId);
return resolveAll(instance, id, scope);
}
const container = instance[PROXYDI_CONTAINER];
if (!container) {
throw new Error('Instance is not registered in any container');
}
return container.resolveAll(dependencyId, scope);
}
exports.DEPENDENCY_ID = DEPENDENCY_ID;
exports.ON_CONTAINERIZED = ON_CONTAINERIZED;
exports.PROXYDI_CONTAINER = PROXYDI_CONTAINER;
exports.ProxyDiContainer = ProxyDiContainer;
exports.inject = inject;
exports.injectAll = injectAll;
exports.injectable = injectable;
exports.middleware = middleware;
exports.resolveAll = resolveAll;