@furystack/inject
Advanced tools
Comparing version 10.0.0 to 11.0.0
@@ -12,3 +12,19 @@ import type { Constructable } from './models/constructable.js'; | ||
export declare const defaultInjectableOptions: InjectableOptions; | ||
export declare const InjectableOptionsSymbol: unique symbol; | ||
type WithInjectableOptions<T> = T & { | ||
[InjectableOptionsSymbol]: InjectableOptions; | ||
}; | ||
/** | ||
* Checks if the constructor is decorated with Injectable() with verifying if it has Injectable options | ||
* @param ctor The constructor to check | ||
* @returns if the constructor has the InjectableOptionsSymbol | ||
*/ | ||
export declare const hasInjectableOptions: <T extends Constructable<any>>(ctor: T) => ctor is WithInjectableOptions<T>; | ||
/** | ||
* @throws Error if the class is not an injectable | ||
* @param ctor The constructor to get the options from | ||
* @returns The InjectableOptions object | ||
*/ | ||
export declare const getInjectableOptions: <T extends Constructable<any>>(ctor: T) => InjectableOptions; | ||
/** | ||
* Decorator method for tagging a class as injectable | ||
@@ -19,2 +35,3 @@ * @param options The options object | ||
export declare const Injectable: (options?: Partial<InjectableOptions>) => <T extends Constructable<any>>(ctor: T) => void; | ||
export {}; | ||
//# sourceMappingURL=injectable.d.ts.map |
@@ -1,2 +0,1 @@ | ||
import { Injector } from './injector.js'; | ||
/** | ||
@@ -8,3 +7,24 @@ * The default options for the injectable classes | ||
}; | ||
export const InjectableOptionsSymbol = Symbol('InjectableOptions'); | ||
/** | ||
* Checks if the constructor is decorated with Injectable() with verifying if it has Injectable options | ||
* @param ctor The constructor to check | ||
* @returns if the constructor has the InjectableOptionsSymbol | ||
*/ | ||
export const hasInjectableOptions = (ctor) => { | ||
return (typeof ctor[InjectableOptionsSymbol] === 'object' && | ||
typeof ctor[InjectableOptionsSymbol].lifetime === 'string'); | ||
}; | ||
/** | ||
* @throws Error if the class is not an injectable | ||
* @param ctor The constructor to get the options from | ||
* @returns The InjectableOptions object | ||
*/ | ||
export const getInjectableOptions = (ctor) => { | ||
if (!hasInjectableOptions(ctor)) { | ||
throw Error(`The class '${ctor.name}' is not an injectable`); | ||
} | ||
return ctor[InjectableOptionsSymbol]; | ||
}; | ||
/** | ||
* Decorator method for tagging a class as injectable | ||
@@ -16,5 +36,7 @@ * @param options The options object | ||
return (ctor) => { | ||
Injector.options.set(ctor, { | ||
...defaultInjectableOptions, | ||
...options, | ||
Object.assign(ctor, { | ||
[InjectableOptionsSymbol]: { | ||
...defaultInjectableOptions, | ||
...options, | ||
}, | ||
}); | ||
@@ -21,0 +43,0 @@ }; |
@@ -7,7 +7,35 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { | ||
}; | ||
import { Injectable } from './injectable.js'; | ||
import { Injector } from './injector.js'; | ||
import { Injectable, InjectableOptionsSymbol, getInjectableOptions, hasInjectableOptions } from './injectable.js'; | ||
import { describe, expect, it } from 'vitest'; | ||
describe('hasInjectableOptions', () => { | ||
it('Should return true if the object has the InjectableOptionsSymbol', () => { | ||
class Alma { | ||
} | ||
Object.assign(Alma, { [InjectableOptionsSymbol]: { lifetime: 'singleton' } }); | ||
expect(hasInjectableOptions(Alma)).toBe(true); | ||
}); | ||
it('Should return false if the object does not have the InjectableOptionsSymbol', () => { | ||
class Alma { | ||
} | ||
expect(hasInjectableOptions(Alma)).toBe(false); | ||
}); | ||
it('Should return false if the object is not an object', () => { | ||
expect(hasInjectableOptions('')).toBe(false); | ||
}); | ||
}); | ||
describe('getInjectableOptions', () => { | ||
it('Should throw an error if the object does not have the InjectableOptionsSymbol', () => { | ||
class Alma { | ||
} | ||
expect(() => getInjectableOptions(Alma)).toThrowError("The class 'Alma' is not an injectable"); | ||
}); | ||
it('Should return the options if the object has the InjectableOptionsSymbol', () => { | ||
class Alma { | ||
} | ||
Object.assign(Alma, { [InjectableOptionsSymbol]: { lifetime: 'singleton' } }); | ||
expect(getInjectableOptions(Alma)).toEqual({ lifetime: 'singleton' }); | ||
}); | ||
}); | ||
describe('@Injectable()', () => { | ||
it('Should fill meta store with default options', () => { | ||
it('Should attach the default options by default', () => { | ||
let TestClass1 = class TestClass1 { | ||
@@ -18,7 +46,7 @@ }; | ||
], TestClass1); | ||
const meta = Injector.options.get(TestClass1); | ||
const meta = getInjectableOptions(TestClass1); | ||
expect(meta).toBeDefined(); | ||
expect(meta?.lifetime).toBe('transient'); | ||
}); | ||
it('Should fill meta store', () => { | ||
it('Should attach the explicitly set options', () => { | ||
let TestClass2 = class TestClass2 { | ||
@@ -29,3 +57,3 @@ }; | ||
], TestClass2); | ||
const meta = Injector.options.get(TestClass2); | ||
const meta = getInjectableOptions(TestClass2); | ||
expect(meta).toBeDefined(); | ||
@@ -32,0 +60,0 @@ expect(meta?.lifetime).toBe('scoped'); |
import type { Constructable } from './models/constructable.js'; | ||
export declare const Injected: <T extends Constructable<unknown>>(ctor: T) => PropertyDecorator; | ||
import type { Injector } from './injector.js'; | ||
export declare const InjectableDependencyList: unique symbol; | ||
export declare const getDependencyList: <T extends Constructable<unknown>>(ctor: T) => Set<Constructable<any>>; | ||
export declare const Injected: <T>(injectable: Constructable<unknown> | ((injector: Injector) => T)) => PropertyDecorator; | ||
//# sourceMappingURL=injected.d.ts.map |
@@ -1,13 +0,43 @@ | ||
import { Injector } from './injector.js'; | ||
export const Injected = (ctor) => (target, propertyKey) => { | ||
const targetCtor = target.constructor; | ||
if (!Injector.injectableFields.has(targetCtor)) { | ||
Injector.injectableFields.set(targetCtor, {}); | ||
import { getInjectorReference } from './with-injector-reference.js'; | ||
import { hasInjectableOptions } from './injectable.js'; | ||
export const InjectableDependencyList = Symbol('InjectableDependencyList'); | ||
export const getDependencyList = (ctor) => { | ||
const existing = ctor[InjectableDependencyList]; | ||
if (existing && existing instanceof Set) { | ||
return existing; | ||
} | ||
const meta = Injector.injectableFields.get(targetCtor); | ||
Injector.injectableFields.set(targetCtor, { | ||
...meta, | ||
[propertyKey]: ctor, | ||
}); | ||
const newSet = new Set(); | ||
Object.assign(ctor, { [InjectableDependencyList]: newSet }); | ||
return newSet; | ||
}; | ||
const addDependency = (ctor, dependency) => { | ||
const list = getDependencyList(ctor); | ||
list.add(dependency); | ||
}; | ||
export const Injected = (injectable) => (target, propertyKey) => { | ||
const hasMeta = hasInjectableOptions(injectable); | ||
// The provided injectable is a constructor | ||
if (hasMeta) { | ||
addDependency(target.constructor, injectable); | ||
Object.defineProperty(target.constructor.prototype, propertyKey, { | ||
set() { | ||
throw new Error(`Injected property '${target.constructor.name}.${propertyKey.toString()}' is read-only`); | ||
}, | ||
get() { | ||
return getInjectorReference(this).getInstance(injectable); | ||
}, | ||
}); | ||
} | ||
else { | ||
// The provided injectable is a getter function | ||
Object.defineProperty(target.constructor.prototype, propertyKey, { | ||
set() { | ||
throw new Error(`Injected property '${target.constructor.name}.${propertyKey.toString()}' is read-only`); | ||
}, | ||
get() { | ||
return injectable.call(this, getInjectorReference(this)); | ||
}, | ||
}); | ||
} | ||
}; | ||
//# sourceMappingURL=injected.js.map |
@@ -10,14 +10,15 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { | ||
}; | ||
import { Injected } from './injected.js'; | ||
import { Injectable } from './injectable.js'; | ||
import { Injected, getDependencyList } from './injected.js'; | ||
import { describe, expect, it } from 'vitest'; | ||
import { Injector } from './injector.js'; | ||
import { describe, expect, it } from 'vitest'; | ||
describe('@Injected()', () => { | ||
it('Should register into the injectable fields', () => { | ||
class Property { | ||
let Property = class Property { | ||
foo = 3; | ||
} | ||
}; | ||
Property = __decorate([ | ||
Injectable() | ||
], Property); | ||
class TestClass { | ||
property; | ||
property2; | ||
property3; | ||
} | ||
@@ -32,9 +33,89 @@ __decorate([ | ||
], TestClass.prototype, "property3", void 0); | ||
expect(Injector.injectableFields.has(TestClass)).toBe(true); | ||
expect(Injector.injectableFields.get(TestClass)).toEqual({ | ||
property: Property, | ||
property3: Property, | ||
}); | ||
const dependencyList = getDependencyList(TestClass); | ||
expect(dependencyList.has(Property)).toBeTruthy(); | ||
}); | ||
it('Should inject a property from the decorator', () => { | ||
let Property = class Property { | ||
foo = 3; | ||
}; | ||
Property = __decorate([ | ||
Injectable() | ||
], Property); | ||
let TestClass = class TestClass { | ||
}; | ||
__decorate([ | ||
Injected(Property), | ||
__metadata("design:type", Property) | ||
], TestClass.prototype, "property", void 0); | ||
TestClass = __decorate([ | ||
Injectable() | ||
], TestClass); | ||
const instance = new Injector().getInstance(TestClass); | ||
expect(instance.property).toBeInstanceOf(Property); | ||
expect(instance.property.foo).toBe(3); | ||
}); | ||
it('Should throw an error when trying to modify the injected property', () => { | ||
let Property = class Property { | ||
foo = 3; | ||
}; | ||
Property = __decorate([ | ||
Injectable() | ||
], Property); | ||
let TestClass = class TestClass { | ||
}; | ||
__decorate([ | ||
Injected(Property), | ||
__metadata("design:type", Property) | ||
], TestClass.prototype, "property", void 0); | ||
TestClass = __decorate([ | ||
Injectable() | ||
], TestClass); | ||
const instance = new Injector().getInstance(TestClass); | ||
expect(() => (instance.property = new Property())).toThrowErrorMatchingInlineSnapshot(`[Error: Injected property 'TestClass.property' is read-only]`); | ||
}); | ||
it('Should inject a property with a callback syntax', () => { | ||
let Property = class Property { | ||
foo = 3; | ||
}; | ||
Property = __decorate([ | ||
Injectable() | ||
], Property); | ||
let TestClass = class TestClass { | ||
initial = 2; | ||
}; | ||
__decorate([ | ||
Injected(function (injector) { | ||
return injector.getInstance(Property).foo + this.initial; | ||
}), | ||
__metadata("design:type", Number) | ||
], TestClass.prototype, "property", void 0); | ||
TestClass = __decorate([ | ||
Injectable() | ||
], TestClass); | ||
const instance = new Injector().getInstance(TestClass); | ||
expect(instance.property).toBe(5); | ||
}); | ||
it('Should throw an error when trying to modify the injected property with a callback syntax', () => { | ||
let Property = class Property { | ||
foo = 3; | ||
}; | ||
Property = __decorate([ | ||
Injectable() | ||
], Property); | ||
let TestClass = class TestClass { | ||
initial = 2; | ||
}; | ||
__decorate([ | ||
Injected(function (injector) { | ||
return injector.getInstance(Property).foo + this.initial; | ||
}), | ||
__metadata("design:type", Number) | ||
], TestClass.prototype, "property", void 0); | ||
TestClass = __decorate([ | ||
Injectable() | ||
], TestClass); | ||
const instance = new Injector().getInstance(TestClass); | ||
expect(() => (instance.property = 3)).toThrowErrorMatchingInlineSnapshot(`[Error: Injected property 'TestClass.property' is read-only]`); | ||
}); | ||
}); | ||
//# sourceMappingURL=injected.spec.js.map |
import type { Disposable } from '@furystack/utils'; | ||
import type { InjectableOptions } from './injectable.js'; | ||
import type { Constructable } from './models/constructable.js'; | ||
export declare class InjectorAlreadyDisposedError extends Error { | ||
constructor(); | ||
} | ||
export declare class Injector implements Disposable { | ||
private isDisposed; | ||
/** | ||
@@ -16,18 +19,11 @@ * Disposes the Injector object and all its disposable injectables | ||
}; | ||
/** | ||
* Static class metadata map, filled by the @Injectable() decorator | ||
*/ | ||
static options: Map<Constructable<any>, InjectableOptions>; | ||
static injectableFields: Map<Constructable<any>, { | ||
[K: string]: Constructable<any>; | ||
}>; | ||
readonly cachedSingletons: Map<Constructable<any>, any>; | ||
remove: <T>(ctor: Constructable<T>) => boolean; | ||
remove: <T>(ctor: Constructable<T>) => void; | ||
getInstance<T>(ctor: Constructable<T>): T; | ||
/** | ||
* | ||
* @param ctor The constructor object (e.g. MyClass) | ||
* @param dependencies Resolved dependencies (usually provided by the framework) | ||
* @returns The instance of the requested service | ||
*/ | ||
getInstance<T>(ctor: Constructable<T>, dependencies?: Array<Constructable<T>>): T; | ||
private getInstanceInternal; | ||
/** | ||
@@ -34,0 +30,0 @@ * Sets explicitliy an instance for a key in the store |
@@ -0,5 +1,21 @@ | ||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { | ||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; | ||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); | ||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; | ||
return c > 3 && r && Object.defineProperty(target, key, r), r; | ||
}; | ||
var Injector_1; | ||
import { Injectable, getInjectableOptions } from './injectable.js'; | ||
import { withInjectorReference } from './with-injector-reference.js'; | ||
import { getDependencyList } from './injected.js'; | ||
const hasInitMethod = (obj) => { | ||
return typeof obj.init === 'function'; | ||
}; | ||
export class Injector { | ||
export class InjectorAlreadyDisposedError extends Error { | ||
constructor() { | ||
super('Injector already disposed'); | ||
} | ||
} | ||
let Injector = Injector_1 = class Injector { | ||
isDisposed = false; | ||
/** | ||
@@ -9,2 +25,6 @@ * Disposes the Injector object and all its disposable injectables | ||
async dispose() { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError(); | ||
} | ||
this.isDisposed = true; | ||
const singletons = Array.from(this.cachedSingletons.entries()).map((e) => e[1]); | ||
@@ -29,16 +49,22 @@ const disposeRequests = singletons | ||
options = {}; | ||
/** | ||
* Static class metadata map, filled by the @Injectable() decorator | ||
*/ | ||
static options = new Map(); | ||
static injectableFields = new Map(); | ||
// public static injectableFields: Map<Constructable<any>, { [K: string]: Constructable<any> }> = new Map() | ||
cachedSingletons = new Map(); | ||
remove = (ctor) => this.cachedSingletons.delete(ctor); | ||
remove = (ctor) => { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError(); | ||
} | ||
this.cachedSingletons.delete(ctor); | ||
}; | ||
getInstance(ctor) { | ||
return withInjectorReference(this.getInstanceInternal(ctor), this); | ||
} | ||
/** | ||
* | ||
* @param ctor The constructor object (e.g. MyClass) | ||
* @param dependencies Resolved dependencies (usually provided by the framework) | ||
* @returns The instance of the requested service | ||
*/ | ||
getInstance(ctor, dependencies = []) { | ||
getInstanceInternal(ctor) { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError(); | ||
} | ||
if (ctor === this.constructor) { | ||
@@ -50,16 +76,11 @@ return this; | ||
} | ||
const meta = Injector.options.get(ctor); | ||
if (!meta) { | ||
throw Error(`No metadata found for '${ctor.name}'. Dependencies: ${dependencies | ||
.map((d) => d.name) | ||
.join(',')}. Be sure that it's decorated with '@Injectable()' or added explicitly with SetInstance()`); | ||
} | ||
const dependencies = [...getDependencyList(ctor)]; | ||
if (dependencies.includes(ctor)) { | ||
throw Error(`Circular dependencies found.`); | ||
} | ||
const { lifetime = 'singleton' } = meta; | ||
const injectedFields = Object.entries(Injector.injectableFields.get(ctor) || {}); | ||
const meta = getInjectableOptions(ctor); | ||
const { lifetime } = meta; | ||
if (lifetime === 'singleton') { | ||
const invalidDeps = [...injectedFields] | ||
.map(([, dep]) => ({ meta: Injector.options.get(dep), dep })) | ||
const invalidDeps = dependencies | ||
.map((dep) => ({ meta: getInjectableOptions(dep), dep })) | ||
.filter((m) => m.meta && (m.meta.lifetime === 'scoped' || m.meta.lifetime === 'transient')) | ||
@@ -72,4 +93,4 @@ .map((i) => i.meta && `${i.dep.name}:${i.meta.lifetime}`); | ||
else if (lifetime === 'scoped') { | ||
const invalidDeps = [...injectedFields.values()] | ||
.map(([, dep]) => ({ meta: Injector.options.get(dep), dep })) | ||
const invalidDeps = dependencies | ||
.map((dep) => ({ meta: getInjectableOptions(dep), dep })) | ||
.filter((m) => m.meta && m.meta.lifetime === 'transient') | ||
@@ -81,11 +102,7 @@ .map((i) => i.meta && `${i.dep.name}:${i.meta.lifetime}`); | ||
} | ||
const fromParent = lifetime === 'singleton' && this.options.parent && this.options.parent.getInstance(ctor); | ||
const fromParent = lifetime === 'singleton' && this.options.parent && this.options.parent.getInstanceInternal(ctor); | ||
if (fromParent) { | ||
return fromParent; | ||
} | ||
const deps = injectedFields.map(([key, dep]) => [key, this.getInstance(dep, [...dependencies, ctor])]); | ||
const newInstance = new ctor(); | ||
deps.forEach(([key, value]) => { | ||
newInstance[key] = value; | ||
}); | ||
if (lifetime !== 'transient') { | ||
@@ -95,3 +112,3 @@ this.setExplicitInstance(newInstance); | ||
if (hasInitMethod(newInstance)) { | ||
newInstance.init(this); | ||
withInjectorReference(newInstance, this).init(this); | ||
} | ||
@@ -106,2 +123,5 @@ return newInstance; | ||
setExplicitInstance(instance, key) { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError(); | ||
} | ||
const ctor = key || instance.constructor; | ||
@@ -119,3 +139,6 @@ if (instance.constructor === this.constructor) { | ||
createChild(options) { | ||
const i = new Injector(); | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError(); | ||
} | ||
const i = new Injector_1(); | ||
i.options = i.options || options; | ||
@@ -125,3 +148,7 @@ i.options.parent = this; | ||
} | ||
} | ||
}; | ||
Injector = Injector_1 = __decorate([ | ||
Injectable({ lifetime: 'system' }) | ||
], Injector); | ||
export { Injector }; | ||
//# sourceMappingURL=injector.js.map |
@@ -24,16 +24,2 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { | ||
}); | ||
it('Should throw an error on circular dependencies', () => { | ||
const i = new Injector(); | ||
let InstanceClass = class InstanceClass { | ||
ohgodno; | ||
}; | ||
__decorate([ | ||
Injected(InstanceClass), | ||
__metadata("design:type", InstanceClass) | ||
], InstanceClass.prototype, "ohgodno", void 0); | ||
InstanceClass = __decorate([ | ||
Injectable() | ||
], InstanceClass); | ||
expect(() => i.getInstance(InstanceClass)).toThrowError('Circular dependencies found.'); | ||
}); | ||
it('Should set and return instance from cache', () => { | ||
@@ -92,4 +78,2 @@ const i = new Injector(); | ||
let InstanceClass = class InstanceClass { | ||
injected1; | ||
injected2; | ||
}; | ||
@@ -120,3 +104,2 @@ __decorate([ | ||
let Injected2 = class Injected2 { | ||
injected1; | ||
}; | ||
@@ -131,3 +114,2 @@ __decorate([ | ||
let InstanceClass = class InstanceClass { | ||
injected2; | ||
}; | ||
@@ -199,3 +181,3 @@ __decorate([ | ||
using(new Injector(), (i) => { | ||
expect(() => i.getInstance(UndecoratedTestClass, [Injector])).toThrowError(`No metadata found for 'UndecoratedTestClass'. Dependencies: Injector. Be sure that it's decorated with '@Injectable()' or added explicitly with SetInstance()`); | ||
expect(() => i.getInstance(UndecoratedTestClass)).toThrowError(`The class 'UndecoratedTestClass' is not an injectable`); | ||
}); | ||
@@ -210,3 +192,2 @@ }); | ||
let St1 = class St1 { | ||
lt; | ||
}; | ||
@@ -231,3 +212,2 @@ __decorate([ | ||
let St2 = class St2 { | ||
sc; | ||
}; | ||
@@ -252,3 +232,2 @@ __decorate([ | ||
let Sc2 = class Sc2 { | ||
sc; | ||
}; | ||
@@ -281,3 +260,30 @@ __decorate([ | ||
}); | ||
describe('Disposed injector', () => { | ||
it('Should throw an error on getInstance', async () => { | ||
const i = new Injector(); | ||
await i.dispose(); | ||
expect(() => i.getInstance(Injector)).toThrowError('Injector already disposed'); | ||
}); | ||
it('Should throw an error on setExplicitInstance', async () => { | ||
const i = new Injector(); | ||
await i.dispose(); | ||
expect(() => i.setExplicitInstance({})).toThrowError('Injector already disposed'); | ||
}); | ||
it('Should throw an error on remove', async () => { | ||
const i = new Injector(); | ||
await i.dispose(); | ||
expect(() => i.remove(Object)).toThrowError('Injector already disposed'); | ||
}); | ||
it('Should throw an error on createChild', async () => { | ||
const i = new Injector(); | ||
await i.dispose(); | ||
expect(() => i.createChild()).toThrowError('Injector already disposed'); | ||
}); | ||
it('Should throw an error on dispose', async () => { | ||
const i = new Injector(); | ||
await i.dispose(); | ||
await expect(async () => await i.dispose()).rejects.toThrowError('Injector already disposed'); | ||
}); | ||
}); | ||
}); | ||
//# sourceMappingURL=injector.spec.js.map |
{ | ||
"name": "@furystack/inject", | ||
"version": "10.0.0", | ||
"version": "11.0.0", | ||
"description": "Core FuryStack package", | ||
@@ -40,6 +40,6 @@ "type": "module", | ||
"dependencies": { | ||
"@furystack/utils": "^6.0.0" | ||
"@furystack/utils": "^6.0.1" | ||
}, | ||
"devDependencies": { | ||
"typescript": "^5.4.2", | ||
"typescript": "^5.4.3", | ||
"vitest": "^1.4.0" | ||
@@ -46,0 +46,0 @@ }, |
@@ -1,10 +0,41 @@ | ||
import { Injectable } from './injectable.js' | ||
import { Injector } from './injector.js' | ||
import { Injectable, InjectableOptionsSymbol, getInjectableOptions, hasInjectableOptions } from './injectable.js' | ||
import { describe, expect, it } from 'vitest' | ||
describe('hasInjectableOptions', () => { | ||
it('Should return true if the object has the InjectableOptionsSymbol', () => { | ||
class Alma {} | ||
Object.assign(Alma, { [InjectableOptionsSymbol]: { lifetime: 'singleton' } }) | ||
expect(hasInjectableOptions(Alma)).toBe(true) | ||
}) | ||
it('Should return false if the object does not have the InjectableOptionsSymbol', () => { | ||
class Alma {} | ||
expect(hasInjectableOptions(Alma)).toBe(false) | ||
}) | ||
it('Should return false if the object is not an object', () => { | ||
expect(hasInjectableOptions('' as any)).toBe(false) | ||
}) | ||
}) | ||
describe('getInjectableOptions', () => { | ||
it('Should throw an error if the object does not have the InjectableOptionsSymbol', () => { | ||
class Alma {} | ||
expect(() => getInjectableOptions(Alma)).toThrowError("The class 'Alma' is not an injectable") | ||
}) | ||
it('Should return the options if the object has the InjectableOptionsSymbol', () => { | ||
class Alma {} | ||
Object.assign(Alma, { [InjectableOptionsSymbol]: { lifetime: 'singleton' } }) | ||
expect(getInjectableOptions(Alma)).toEqual({ lifetime: 'singleton' }) | ||
}) | ||
}) | ||
describe('@Injectable()', () => { | ||
it('Should fill meta store with default options', () => { | ||
it('Should attach the default options by default', () => { | ||
@Injectable() | ||
class TestClass1 {} | ||
const meta = Injector.options.get(TestClass1) | ||
const meta = getInjectableOptions(TestClass1) | ||
expect(meta).toBeDefined() | ||
@@ -14,6 +45,6 @@ expect(meta?.lifetime).toBe('transient') | ||
it('Should fill meta store', () => { | ||
it('Should attach the explicitly set options', () => { | ||
@Injectable({ lifetime: 'scoped' }) | ||
class TestClass2 {} | ||
const meta = Injector.options.get(TestClass2) | ||
const meta = getInjectableOptions(TestClass2) | ||
expect(meta).toBeDefined() | ||
@@ -20,0 +51,0 @@ expect(meta?.lifetime).toBe('scoped') |
@@ -1,2 +0,1 @@ | ||
import { Injector } from './injector.js' | ||
import type { Constructable } from './models/constructable.js' | ||
@@ -18,3 +17,31 @@ | ||
export const InjectableOptionsSymbol = Symbol('InjectableOptions') | ||
type WithInjectableOptions<T> = T & { [InjectableOptionsSymbol]: InjectableOptions } | ||
/** | ||
* Checks if the constructor is decorated with Injectable() with verifying if it has Injectable options | ||
* @param ctor The constructor to check | ||
* @returns if the constructor has the InjectableOptionsSymbol | ||
*/ | ||
export const hasInjectableOptions = <T extends Constructable<any>>(ctor: T): ctor is WithInjectableOptions<T> => { | ||
return ( | ||
typeof (ctor as any)[InjectableOptionsSymbol] === 'object' && | ||
typeof ((ctor as any)[InjectableOptionsSymbol] as InjectableOptions).lifetime === 'string' | ||
) | ||
} | ||
/** | ||
* @throws Error if the class is not an injectable | ||
* @param ctor The constructor to get the options from | ||
* @returns The InjectableOptions object | ||
*/ | ||
export const getInjectableOptions = <T extends Constructable<any>>(ctor: T): InjectableOptions => { | ||
if (!hasInjectableOptions(ctor)) { | ||
throw Error(`The class '${ctor.name}' is not an injectable`) | ||
} | ||
return ctor[InjectableOptionsSymbol] | ||
} | ||
/** | ||
* Decorator method for tagging a class as injectable | ||
@@ -26,7 +53,9 @@ * @param options The options object | ||
return <T extends Constructable<any>>(ctor: T) => { | ||
Injector.options.set(ctor, { | ||
...defaultInjectableOptions, | ||
...options, | ||
Object.assign(ctor, { | ||
[InjectableOptionsSymbol]: { | ||
...defaultInjectableOptions, | ||
...options, | ||
}, | ||
}) | ||
} | ||
} |
@@ -1,6 +0,8 @@ | ||
import { Injected } from './injected.js' | ||
import { Injectable } from './injectable.js' | ||
import { Injected, getDependencyList } from './injected.js' | ||
import { describe, expect, it } from 'vitest' | ||
import { Injector } from './injector.js' | ||
import { describe, expect, it } from 'vitest' | ||
describe('@Injected()', () => { | ||
it('Should register into the injectable fields', () => { | ||
@Injectable() | ||
class Property { | ||
@@ -11,16 +13,86 @@ foo = 3 | ||
@Injected(Property) | ||
public property!: Property | ||
declare property: Property | ||
property2?: Property | ||
declare property2?: Property | ||
@Injected(Property) | ||
property3!: Property | ||
declare property3: Property | ||
} | ||
expect(Injector.injectableFields.has(TestClass)).toBe(true) | ||
expect(Injector.injectableFields.get(TestClass)).toEqual({ | ||
property: Property, | ||
property3: Property, | ||
}) | ||
const dependencyList = getDependencyList(TestClass) | ||
expect(dependencyList.has(Property)).toBeTruthy() | ||
}) | ||
it('Should inject a property from the decorator', () => { | ||
@Injectable() | ||
class Property { | ||
foo = 3 | ||
} | ||
@Injectable() | ||
class TestClass { | ||
@Injected(Property) | ||
declare property: Property | ||
} | ||
const instance = new Injector().getInstance(TestClass) | ||
expect(instance.property).toBeInstanceOf(Property) | ||
expect(instance.property.foo).toBe(3) | ||
}) | ||
it('Should throw an error when trying to modify the injected property', () => { | ||
@Injectable() | ||
class Property { | ||
foo = 3 | ||
} | ||
@Injectable() | ||
class TestClass { | ||
@Injected(Property) | ||
declare property: Property | ||
} | ||
const instance = new Injector().getInstance(TestClass) | ||
expect(() => (instance.property = new Property())).toThrowErrorMatchingInlineSnapshot( | ||
`[Error: Injected property 'TestClass.property' is read-only]`, | ||
) | ||
}) | ||
it('Should inject a property with a callback syntax', () => { | ||
@Injectable() | ||
class Property { | ||
foo = 3 | ||
} | ||
@Injectable() | ||
class TestClass { | ||
private initial = 2 | ||
@Injected(function (this: TestClass, injector) { | ||
return injector.getInstance(Property).foo + this.initial | ||
}) | ||
declare property: number | ||
} | ||
const instance = new Injector().getInstance(TestClass) | ||
expect(instance.property).toBe(5) | ||
}) | ||
it('Should throw an error when trying to modify the injected property with a callback syntax', () => { | ||
@Injectable() | ||
class Property { | ||
foo = 3 | ||
} | ||
@Injectable() | ||
class TestClass { | ||
private initial = 2 | ||
@Injected(function (this: TestClass, injector) { | ||
return injector.getInstance(Property).foo + this.initial | ||
}) | ||
declare property: number | ||
} | ||
const instance = new Injector().getInstance(TestClass) | ||
expect(() => (instance.property = 3)).toThrowErrorMatchingInlineSnapshot( | ||
`[Error: Injected property 'TestClass.property' is read-only]`, | ||
) | ||
}) | ||
}) |
@@ -1,15 +0,48 @@ | ||
import { Injector } from './injector.js' | ||
import { getInjectorReference } from './with-injector-reference.js' | ||
import type { Constructable } from './models/constructable.js' | ||
import type { Injector } from './injector.js' | ||
import { hasInjectableOptions } from './injectable.js' | ||
export const Injected: <T extends Constructable<unknown>>(ctor: T) => PropertyDecorator = | ||
(ctor) => (target, propertyKey) => { | ||
const targetCtor = target.constructor as Constructable<any> | ||
if (!Injector.injectableFields.has(targetCtor)) { | ||
Injector.injectableFields.set(targetCtor, {}) | ||
export const InjectableDependencyList = Symbol('InjectableDependencyList') | ||
export const getDependencyList = <T extends Constructable<unknown>>(ctor: T): Set<Constructable<any>> => { | ||
const existing = (ctor as any)[InjectableDependencyList] as Set<Constructable<any>> | ||
if (existing && existing instanceof Set) { | ||
return existing | ||
} | ||
const newSet = new Set<Constructable<any>>() | ||
Object.assign(ctor, { [InjectableDependencyList]: newSet }) | ||
return newSet | ||
} | ||
const addDependency = <T extends Constructable<unknown>>(ctor: T, dependency: Constructable<any>) => { | ||
const list = getDependencyList(ctor) | ||
list.add(dependency) | ||
} | ||
export const Injected: <T>(injectable: Constructable<unknown> | ((injector: Injector) => T)) => PropertyDecorator = | ||
(injectable) => (target, propertyKey) => { | ||
const hasMeta = hasInjectableOptions(injectable as Constructable<unknown>) | ||
// The provided injectable is a constructor | ||
if (hasMeta) { | ||
addDependency(target.constructor as Constructable<unknown>, injectable as Constructable<unknown>) | ||
Object.defineProperty(target.constructor.prototype, propertyKey, { | ||
set() { | ||
throw new Error(`Injected property '${target.constructor.name}.${propertyKey.toString()}' is read-only`) | ||
}, | ||
get() { | ||
return getInjectorReference(this).getInstance(injectable as Constructable<unknown>) | ||
}, | ||
}) | ||
} else { | ||
// The provided injectable is a getter function | ||
Object.defineProperty(target.constructor.prototype, propertyKey, { | ||
set() { | ||
throw new Error(`Injected property '${target.constructor.name}.${propertyKey.toString()}' is read-only`) | ||
}, | ||
get() { | ||
return (injectable as (injector: Injector) => unknown).call(this, getInjectorReference(this)) | ||
}, | ||
}) | ||
} | ||
const meta = Injector.injectableFields.get(targetCtor) | ||
Injector.injectableFields.set(targetCtor, { | ||
...meta, | ||
[propertyKey]: ctor, | ||
}) | ||
} |
@@ -19,12 +19,2 @@ import type { Disposable } from '@furystack/utils' | ||
it('Should throw an error on circular dependencies', () => { | ||
const i = new Injector() | ||
@Injectable() | ||
class InstanceClass { | ||
@Injected(InstanceClass) | ||
public ohgodno!: InstanceClass | ||
} | ||
expect(() => i.getInstance(InstanceClass)).toThrowError('Circular dependencies found.') | ||
}) | ||
it('Should set and return instance from cache', () => { | ||
@@ -76,6 +66,6 @@ const i = new Injector() | ||
@Injected(Injected1) | ||
public injected1!: Injected1 | ||
declare injected1: Injected1 | ||
@Injected(Injected2) | ||
public injected2!: Injected2 | ||
declare injected2: Injected2 | ||
} | ||
@@ -98,3 +88,3 @@ | ||
@Injected(Injected1) | ||
public injected1!: Injected1 | ||
declare injected1: Injected1 | ||
} | ||
@@ -105,3 +95,3 @@ | ||
@Injected(Injected2) | ||
public injected2!: Injected2 | ||
declare injected2: Injected2 | ||
} | ||
@@ -173,4 +163,4 @@ expect(i.getInstance(InstanceClass)).toBeInstanceOf(InstanceClass) | ||
using(new Injector(), (i) => { | ||
expect(() => i.getInstance(UndecoratedTestClass, [Injector])).toThrowError( | ||
`No metadata found for 'UndecoratedTestClass'. Dependencies: Injector. Be sure that it's decorated with '@Injectable()' or added explicitly with SetInstance()`, | ||
expect(() => i.getInstance(UndecoratedTestClass)).toThrowError( | ||
`The class 'UndecoratedTestClass' is not an injectable`, | ||
) | ||
@@ -187,3 +177,3 @@ }) | ||
@Injected(Trs1) | ||
lt!: Trs1 | ||
declare lt: Trs1 | ||
} | ||
@@ -205,3 +195,3 @@ | ||
@Injected(Sc1) | ||
public sc!: Sc1 | ||
declare sc: Sc1 | ||
} | ||
@@ -223,3 +213,3 @@ | ||
@Injected(Tr2) | ||
public sc!: Tr2 | ||
declare sc: Tr2 | ||
} | ||
@@ -248,2 +238,34 @@ | ||
}) | ||
describe('Disposed injector', () => { | ||
it('Should throw an error on getInstance', async () => { | ||
const i = new Injector() | ||
await i.dispose() | ||
expect(() => i.getInstance(Injector)).toThrowError('Injector already disposed') | ||
}) | ||
it('Should throw an error on setExplicitInstance', async () => { | ||
const i = new Injector() | ||
await i.dispose() | ||
expect(() => i.setExplicitInstance({})).toThrowError('Injector already disposed') | ||
}) | ||
it('Should throw an error on remove', async () => { | ||
const i = new Injector() | ||
await i.dispose() | ||
expect(() => i.remove(Object)).toThrowError('Injector already disposed') | ||
}) | ||
it('Should throw an error on createChild', async () => { | ||
const i = new Injector() | ||
await i.dispose() | ||
expect(() => i.createChild()).toThrowError('Injector already disposed') | ||
}) | ||
it('Should throw an error on dispose', async () => { | ||
const i = new Injector() | ||
await i.dispose() | ||
await expect(async () => await i.dispose()).rejects.toThrowError('Injector already disposed') | ||
}) | ||
}) | ||
}) |
import type { Disposable } from '@furystack/utils' | ||
import type { InjectableOptions } from './injectable.js' | ||
import { Injectable, getInjectableOptions } from './injectable.js' | ||
import type { Constructable } from './models/constructable.js' | ||
import { withInjectorReference } from './with-injector-reference.js' | ||
import { getDependencyList } from './injected.js' | ||
@@ -9,3 +11,12 @@ const hasInitMethod = (obj: Object): obj is { init: (injector: Injector) => void } => { | ||
export class InjectorAlreadyDisposedError extends Error { | ||
constructor() { | ||
super('Injector already disposed') | ||
} | ||
} | ||
@Injectable({ lifetime: 'system' as any }) | ||
export class Injector implements Disposable { | ||
private isDisposed = false | ||
/** | ||
@@ -15,2 +26,8 @@ * Disposes the Injector object and all its disposable injectables | ||
public async dispose() { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError() | ||
} | ||
this.isDisposed = true | ||
const singletons = Array.from(this.cachedSingletons.entries()).map((e) => e[1]) | ||
@@ -42,20 +59,28 @@ const disposeRequests = singletons | ||
/** | ||
* Static class metadata map, filled by the @Injectable() decorator | ||
*/ | ||
public static options: Map<Constructable<any>, InjectableOptions> = new Map() | ||
// public static injectableFields: Map<Constructable<any>, { [K: string]: Constructable<any> }> = new Map() | ||
public static injectableFields: Map<Constructable<any>, { [K: string]: Constructable<any> }> = new Map() | ||
public readonly cachedSingletons: Map<Constructable<any>, any> = new Map() | ||
public remove = <T>(ctor: Constructable<T>) => this.cachedSingletons.delete(ctor) | ||
public remove = <T>(ctor: Constructable<T>) => { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError() | ||
} | ||
this.cachedSingletons.delete(ctor) | ||
} | ||
public getInstance<T>(ctor: Constructable<T>): T { | ||
return withInjectorReference(this.getInstanceInternal(ctor), this) | ||
} | ||
/** | ||
* | ||
* @param ctor The constructor object (e.g. MyClass) | ||
* @param dependencies Resolved dependencies (usually provided by the framework) | ||
* @returns The instance of the requested service | ||
*/ | ||
public getInstance<T>(ctor: Constructable<T>, dependencies: Array<Constructable<T>> = []): T { | ||
private getInstanceInternal<T>(ctor: Constructable<T>): T { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError() | ||
} | ||
if (ctor === this.constructor) { | ||
@@ -69,10 +94,4 @@ return this as any as T | ||
const meta = Injector.options.get(ctor) | ||
if (!meta) { | ||
throw Error( | ||
`No metadata found for '${ctor.name}'. Dependencies: ${dependencies | ||
.map((d) => d.name) | ||
.join(',')}. Be sure that it's decorated with '@Injectable()' or added explicitly with SetInstance()`, | ||
) | ||
} | ||
const dependencies = [...getDependencyList(ctor)] | ||
if (dependencies.includes(ctor)) { | ||
@@ -82,9 +101,9 @@ throw Error(`Circular dependencies found.`) | ||
const { lifetime = 'singleton' } = meta | ||
const meta = getInjectableOptions(ctor) | ||
const injectedFields = Object.entries(Injector.injectableFields.get(ctor) || {}) | ||
const { lifetime } = meta | ||
if (lifetime === 'singleton') { | ||
const invalidDeps = [...injectedFields] | ||
.map(([, dep]) => ({ meta: Injector.options.get(dep), dep })) | ||
const invalidDeps = dependencies | ||
.map((dep) => ({ meta: getInjectableOptions(dep), dep })) | ||
.filter((m) => m.meta && (m.meta.lifetime === 'scoped' || m.meta.lifetime === 'transient')) | ||
@@ -100,4 +119,4 @@ .map((i) => i.meta && `${i.dep.name}:${i.meta.lifetime}`) | ||
} else if (lifetime === 'scoped') { | ||
const invalidDeps = [...injectedFields.values()] | ||
.map(([, dep]) => ({ meta: Injector.options.get(dep), dep })) | ||
const invalidDeps = dependencies | ||
.map((dep) => ({ meta: getInjectableOptions(dep), dep })) | ||
.filter((m) => m.meta && m.meta.lifetime === 'transient') | ||
@@ -112,11 +131,7 @@ .map((i) => i.meta && `${i.dep.name}:${i.meta.lifetime}`) | ||
const fromParent = lifetime === 'singleton' && this.options.parent && this.options.parent.getInstance(ctor) | ||
const fromParent = lifetime === 'singleton' && this.options.parent && this.options.parent.getInstanceInternal(ctor) | ||
if (fromParent) { | ||
return fromParent | ||
} | ||
const deps = injectedFields.map(([key, dep]) => [key, this.getInstance(dep, [...dependencies, ctor])]) | ||
const newInstance = new ctor() | ||
deps.forEach(([key, value]) => { | ||
newInstance[key as keyof T] = value | ||
}) | ||
if (lifetime !== 'transient') { | ||
@@ -127,3 +142,3 @@ this.setExplicitInstance(newInstance) | ||
if (hasInitMethod(newInstance)) { | ||
newInstance.init(this) | ||
withInjectorReference(newInstance, this).init(this) | ||
} | ||
@@ -139,2 +154,6 @@ return newInstance | ||
public setExplicitInstance<T extends object>(instance: T, key?: Constructable<any>) { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError() | ||
} | ||
const ctor = key || (instance.constructor as Constructable<T>) | ||
@@ -154,2 +173,6 @@ | ||
public createChild(options?: Partial<Injector['options']>) { | ||
if (this.isDisposed) { | ||
throw new InjectorAlreadyDisposedError() | ||
} | ||
const i = new Injector() | ||
@@ -156,0 +179,0 @@ i.options = i.options || options |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
104741
54
1415
Updated@furystack/utils@^6.0.1