decorator-dependency-injection 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,22 +1,50 @@
1
1
  /**
2
- * @typedef {Object} InstanceContext
3
- * @property {'singleton'|'factory'} type - The type of the instance.
4
- * @property {Function} clazz - The class constructor for the instance.
5
- * @property {Function} [originalClazz] - The original class if this is a mock.
6
- * @property {Object} [instance] - The singleton instance, if created.
7
- * @property {Object} [originalInstance] - The original instance if this is a mock.
8
- * @property {boolean} [proxy=false] - If true, the mock will proxy to the original class for undefined methods/properties.
2
+ * Decorator Dependency Injection
3
+ *
4
+ * A simple library for dependency injection using TC39 Stage 3 decorators.
5
+ *
6
+ * @module decorator-dependency-injection
9
7
  */
10
8
 
11
- /** @type {Map<string|Class, InstanceContext>} */
12
- const instances = new Map()
9
+ import {Container} from './src/Container.js'
10
+
11
+ /** @type {Container} The default global container */
12
+ const defaultContainer = new Container()
13
+
14
+ /**
15
+ * Creates a lazy accessor descriptor with WeakMap-based caching.
16
+ * @param {WeakMap} cache - WeakMap for per-instance caching
17
+ * @param {Function} getValue - Factory function to create the value
18
+ * @param {string} name - The accessor name for error messages
19
+ * @returns {{init: Function, get: Function, set: Function}} Accessor descriptor
20
+ * @private
21
+ */
22
+ function createLazyAccessor(cache, getValue, name) {
23
+ return {
24
+ init(initialValue) {
25
+ if (initialValue) {
26
+ throw new Error(`Cannot assign value to injected accessor "${name}"`)
27
+ }
28
+ return undefined
29
+ },
30
+ get() {
31
+ if (!cache.has(this)) {
32
+ cache.set(this, getValue())
33
+ }
34
+ return cache.get(this)
35
+ },
36
+ set() {
37
+ throw new Error(`Cannot assign value to injected accessor "${name}"`)
38
+ }
39
+ }
40
+ }
13
41
 
14
42
  /**
15
43
  * Register a class as a singleton. If a name is provided, it will be used as the key in the singleton map.
16
44
  * Singleton instances only ever have one instance created via the @Inject decorator.
17
45
  *
18
46
  * @param {string} [name] The name of the singleton. If not provided, the class will be used as the key.
19
- * @return {(function(Function, {kind: string}): void)}
47
+ * @returns {(function(Function, {kind: string}): void)}
20
48
  * @example @Singleton() class MySingleton {}
21
49
  * @example @Singleton('customName') class MySingleton {}
22
50
  * @throws {Error} If the injection target is not a class
@@ -31,11 +59,7 @@ export function Singleton(name) {
31
59
  if (typeof clazz !== 'function' || !clazz.prototype) {
32
60
  throw new Error('Target must be a class constructor')
33
61
  }
34
- const key = name ?? clazz
35
- if (instances.has(key)) {
36
- throw new Error('A different class is already registered under this name. This may be possibly a circular dependency. Try using @InjectLazy')
37
- }
38
- instances.set(key, {clazz, type: 'singleton'})
62
+ defaultContainer.registerSingleton(clazz, name)
39
63
  }
40
64
  }
41
65
 
@@ -44,7 +68,7 @@ export function Singleton(name) {
44
68
  * Factory instances are created via the @Inject decorator. Each call to the factory will create a new instance.
45
69
  *
46
70
  * @param {string} [name] The name of the factory. If not provided, the class will be used as the key.
47
- * @return {(function(Function, {kind: string}): void)}
71
+ * @returns {(function(Function, {kind: string}): void)}
48
72
  * @example @Factory() class MyFactory {}
49
73
  * @example @Factory('customName') class MyFactory {}
50
74
  * @throws {Error} If the injection target is not a class
@@ -59,11 +83,7 @@ export function Factory(name) {
59
83
  if (typeof clazz !== 'function' || !clazz.prototype) {
60
84
  throw new Error('Target must be a class constructor')
61
85
  }
62
- const key = name ?? clazz
63
- if (instances.has(key)) {
64
- throw new Error('A different class is already registered under this name, This may be possibly a circular dependency. Try using @InjectLazy')
65
- }
66
- instances.set(key, {clazz, type: 'factory'})
86
+ defaultContainer.registerFactory(clazz, name)
67
87
  }
68
88
  }
69
89
 
@@ -71,87 +91,131 @@ export function Factory(name) {
71
91
  * Inject a singleton or factory instance into a class field. You can also provide parameters to the constructor.
72
92
  * If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
73
93
  *
74
- * @param {string|Class} clazzOrName The singleton or factory class or name
75
- * @param {*} params Parameters to pass to the constructor. Recommended to use only with factories.
76
- * @return {(function(*, {kind: string, name: string}): void)}
94
+ * Supports:
95
+ * - Public fields: @Inject(MyClass) myField
96
+ * - Private fields: @Inject(MyClass) #myField
97
+ * - Accessors: @Inject(MyClass) accessor myField
98
+ * - Private accessors: @Inject(MyClass) accessor #myField
99
+ *
100
+ * @param {string|Function} clazzOrName The singleton or factory class or name
101
+ * @param {...*} params Parameters to pass to the constructor. Recommended to use only with factories.
102
+ * @returns {(function(*, {kind: string, name: string}): function(): Object)}
77
103
  * @example @Inject(MySingleton) mySingleton
78
104
  * @example @Inject("myCustomName") myFactory
79
- * @throws {Error} If the injection target is not a field
105
+ * @example @Inject(MyService) #privateService
106
+ * @example @Inject(MyService) accessor myService
107
+ * @throws {Error} If the injection target is not a field or accessor
80
108
  * @throws {Error} If the injected field is assigned a value
81
109
  */
82
110
  export function Inject(clazzOrName, ...params) {
83
- return function (initialValue, context) {
84
- if (context.kind !== 'field') {
85
- throw new Error('Invalid injection target')
111
+ return function (_, context) {
112
+ const getValue = () => {
113
+ const instanceContext = defaultContainer.getContext(clazzOrName)
114
+ return defaultContainer.getInstance(instanceContext, params)
86
115
  }
87
- return function (initialValue) {
88
- if (initialValue) {
89
- throw new Error('Cannot assign value to injected field')
116
+
117
+ if (context.kind === 'field') {
118
+ return function (initialValue) {
119
+ if (initialValue) {
120
+ throw new Error(`Cannot assign value to injected field "${context.name}"`)
121
+ }
122
+ return getValue()
90
123
  }
91
- const instanceContext = getContext(clazzOrName)
92
- return getInjectedInstance(instanceContext, params)
93
124
  }
125
+
126
+ if (context.kind === 'accessor') {
127
+ const cache = new WeakMap()
128
+ return createLazyAccessor(cache, getValue, context.name)
129
+ }
130
+
131
+ throw new Error('Invalid injection target: @Inject can only be used on fields or accessors')
94
132
  }
95
133
  }
96
134
 
97
135
  /**
98
136
  * Inject a singleton or factory instance lazily into a class field. You can also provide parameters to the constructor.
99
137
  * If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
100
- * @param {string|Class} clazzOrName The singleton or factory class or name
101
- * @param {*} params Parameters to pass to the constructor. Recommended to use only with factories.
102
- * @return {(function(*, {kind: string, name: string, addInitializer: Function}): void)}
138
+ *
139
+ * The lazy injection defers instantiation until the field is first accessed. This is useful for:
140
+ * - Breaking circular dependencies
141
+ * - Deferring expensive initializations
142
+ *
143
+ * Supports:
144
+ * - Public fields: @InjectLazy(MyClass) myField
145
+ * - Private fields: @InjectLazy(MyClass) #myField
146
+ * - Accessors: @InjectLazy(MyClass) accessor myField
147
+ * - Private accessors: @InjectLazy(MyClass) accessor #myField
148
+ *
149
+ * Note: For private fields, the lazy behavior is achieved through the field initializer
150
+ * returning a getter-based proxy. For accessors, it's achieved through the accessor's
151
+ * get/set methods directly.
152
+ *
153
+ * @param {string|Function} clazzOrName The singleton or factory class or name
154
+ * @param {...*} params Parameters to pass to the constructor. Recommended to use only with factories.
155
+ * @returns {(function(*, {kind: string, name: string, addInitializer: Function}): void)}
103
156
  * @example @InjectLazy(MySingleton) mySingleton
104
157
  * @example @InjectLazy("myCustomName") myFactory
105
- * @throws {Error} If the injection target is not a field
158
+ * @example @InjectLazy(MyService) #privateService
159
+ * @throws {Error} If the injection target is not a field or accessor
106
160
  * @throws {Error} If the injected field is assigned a value
107
161
  */
108
162
  export function InjectLazy(clazzOrName, ...params) {
109
163
  const cache = new WeakMap()
110
- return (initialValue, context) => {
111
- if (context.kind !== 'field') {
112
- throw new Error('Invalid injection target')
113
- }
114
- context.addInitializer(function () {
115
- Object.defineProperty(this, context.name, {
116
- get() {
117
- if (!cache.has(this)) {
118
- const instanceContext = getContext(clazzOrName)
119
- const value = getInjectedInstance(instanceContext, params)
120
- cache.set(this, value)
121
- }
122
- return cache.get(this)
123
- },
124
- configurable: true,
125
- enumerable: true
126
- })
127
- })
164
+
165
+ const getValue = () => {
166
+ const instanceContext = defaultContainer.getContext(clazzOrName)
167
+ return defaultContainer.getInstance(instanceContext, params)
128
168
  }
129
- }
130
169
 
131
- /**
132
- * Get a proxy for the mock instance. This allows the mock to call methods on the original class if they are not defined in the mock.
133
- * @param {Object} mock The mock instance
134
- * @param {Object} original The original class instance
135
- * @return {*|object} The proxy instance
136
- */
137
- function getProxy(mock, original) {
138
- return new Proxy(mock, {
139
- get(target, prop, receiver) {
140
- if (prop in target) {
141
- return Reflect.get(target, prop, receiver)
170
+ return (_, context) => {
171
+ if (context.kind === 'field') {
172
+ // For private fields, we cannot use Object.defineProperty to create a lazy getter.
173
+ // Instead, we eagerly create the value. For true lazy behavior, use accessor syntax.
174
+ if (context.private) {
175
+ return function (initialValue) {
176
+ if (initialValue) {
177
+ throw new Error(`Cannot assign value to lazy-injected field "${context.name}"`)
178
+ }
179
+ return getValue()
180
+ }
142
181
  }
143
- return Reflect.get(original, prop, receiver)
182
+
183
+ // For public fields, use Object.defineProperty for true lazy behavior
184
+ context.addInitializer(function () {
185
+ Object.defineProperty(this, context.name, {
186
+ get() {
187
+ if (!cache.has(this)) {
188
+ cache.set(this, getValue())
189
+ }
190
+ return cache.get(this)
191
+ },
192
+ set() {
193
+ throw new Error(`Cannot assign value to lazy-injected field "${context.name}"`)
194
+ },
195
+ configurable: true,
196
+ enumerable: true
197
+ })
198
+ })
199
+ return
200
+ }
201
+
202
+ if (context.kind === 'accessor') {
203
+ return createLazyAccessor(cache, getValue, context.name)
144
204
  }
145
- })
205
+
206
+ throw new Error('Invalid injection target: @InjectLazy can only be used on fields or accessors')
207
+ }
146
208
  }
147
209
 
148
210
  /**
149
211
  * Mark a class as a mock. This will replace the class with a mock instance when injected.
150
- * @param {string|Class} mockedClazzOrName The singleton or factory class or name to be mocked
151
- * @param {boolean} [proxy=false] If true, the mock will be a proxy to the original class. Any methods not defined in the mock will be called on the original class.
152
- * @return {(function(Function, {kind: string}): void)}
212
+ *
213
+ * @param {string|Function} mockedClazzOrName The singleton or factory class or name to be mocked
214
+ * @param {boolean} [proxy=false] If true, the mock will proxy to the original class.
215
+ * Any methods not defined in the mock will be called on the original class.
216
+ * @returns {(function(Function, {kind: string}): void)}
153
217
  * @example @Mock(MySingleton) class MyMock {}
154
- * @example @Mock("myCustomName") class MyMock {}
218
+ * @example @Mock("myCustomName", true) class MyMock {}
155
219
  * @throws {Error} If the injection target is not a class
156
220
  * @throws {Error} If the injection source is not found
157
221
  */
@@ -160,97 +224,97 @@ export function Mock(mockedClazzOrName, proxy = false) {
160
224
  if (context.kind !== 'class') {
161
225
  throw new Error('Invalid injection target')
162
226
  }
163
- const instanceContext = getContext(mockedClazzOrName)
164
- if (instanceContext.originalClazz) {
165
- throw new Error('Mock already defined, reset before mocking again')
166
- }
167
- instanceContext.originalClazz = instanceContext.clazz
168
- instanceContext.proxy = proxy
169
- instanceContext.clazz = clazz
227
+ defaultContainer.registerMock(mockedClazzOrName, clazz, proxy)
170
228
  }
171
229
  }
172
230
 
173
231
  /**
174
- * Internal: Get the context for a given class or name.
232
+ * Reset all mocks to their original classes.
233
+ */
234
+ export function resetMocks() {
235
+ defaultContainer.resetAllMocks()
236
+ }
237
+
238
+ /**
239
+ * Reset a specific mock to its original class.
175
240
  *
176
- * @param {string|Class} mockedClazzOrName - The class or name to look up.
177
- * @returns {InstanceContext}
178
- * @throws {Error} If the context is not found.
241
+ * @param {string|Function} clazzOrName The singleton or factory class or name to reset
179
242
  */
180
- function getContext(mockedClazzOrName) {
181
- if (instances.has(mockedClazzOrName)) {
182
- return instances.get(mockedClazzOrName)
183
- } else {
184
- const available = Array.from(instances.keys()).map(k => typeof k === 'string' ? k : k.name).join(', ')
185
- throw new Error(
186
- `Cannot find injection source for "${mockedClazzOrName?.name || mockedClazzOrName}". ` +
187
- `Available: [${available}]`
188
- )
189
- }
243
+ export function resetMock(clazzOrName) {
244
+ defaultContainer.resetMock(clazzOrName)
190
245
  }
191
246
 
192
247
  /**
193
- * Reset all mocks to their original classes.
248
+ * Clear all registered instances and mocks from the container.
249
+ * Useful for complete test isolation between test suites.
194
250
  */
195
- export function resetMocks() {
196
- for (const instanceContext of instances.values()) {
197
- restoreOriginal(instanceContext)
198
- }
251
+ export function clearContainer() {
252
+ defaultContainer.clear()
199
253
  }
200
254
 
201
255
  /**
202
- * Reset a specific mock to its original class.
203
- * @param {string|Class} clazzOrName The singleton or factory class or name to reset
256
+ * Get the default container instance.
257
+ * Useful for advanced use cases or testing the container itself.
258
+ *
259
+ * @returns {Container} The default container
204
260
  */
205
- export function resetMock(clazzOrName) {
206
- restoreOriginal(getContext(clazzOrName))
261
+ export function getContainer() {
262
+ return defaultContainer
207
263
  }
208
264
 
209
265
  /**
210
- * Internal function to reset an instance context to its original.
211
- * @param {InstanceContext} instanceContext The instance context to reset
212
- * @private
266
+ * Enable or disable debug logging for dependency injection.
267
+ * When enabled, logs when instances are registered, created, and mocked.
268
+ *
269
+ * @param {boolean} enabled Whether to enable debug mode
270
+ * @example
271
+ * setDebug(true)
272
+ * // [DI] Registered singleton: UserService
273
+ * // [DI] Creating singleton: UserService
213
274
  */
214
- function restoreOriginal(instanceContext) {
215
- if (!instanceContext) {
216
- throw new Error('Cannot find injection source with the provided name')
217
- }
218
- if (instanceContext.originalClazz) {
219
- instanceContext.clazz = instanceContext.originalClazz
220
- delete instanceContext.instance
221
- delete instanceContext.originalClazz
222
- delete instanceContext.originalInstance
223
- }
275
+ export function setDebug(enabled) {
276
+ defaultContainer.setDebug(enabled)
224
277
  }
225
278
 
226
279
  /**
227
- * Get the injected instance based on the context and parameters.
228
- * @param {InstanceContext} instanceContext The instance context
229
- * @param {Array} params The parameters to pass to the constructor
230
- * @return {Object} The injected instance
280
+ * Check if a class or name is registered in the default container.
281
+ * Useful for validation before injection.
282
+ *
283
+ * @param {string|Function} clazzOrName The class or name to check
284
+ * @returns {boolean} true if registered, false otherwise
285
+ * @example
286
+ * if (!isRegistered(MyService)) {
287
+ * console.warn('MyService not registered!')
288
+ * }
231
289
  */
232
- function getInjectedInstance(instanceContext, params) {
233
- if (instanceContext.type === 'singleton' && !instanceContext.originalClazz && instanceContext.instance) {
234
- return instanceContext.instance
235
- }
236
- let instance
237
- try {
238
- instance = new instanceContext.clazz(...params)
239
- } catch (err) {
240
- if (err instanceof RangeError) {
241
- throw new Error(
242
- `Circular dependency detected for ${instanceContext.clazz.name || instanceContext.clazz}. ` +
243
- `Use @InjectLazy to break the cycle.`
244
- )
245
- }
246
- throw err
247
- }
248
- if (instanceContext.proxy && instanceContext.originalClazz) {
249
- const originalInstance = new instanceContext.originalClazz(...params)
250
- instance = getProxy(instance, originalInstance)
251
- }
252
- if (instanceContext.type === 'singleton') {
253
- instanceContext.instance = instance
290
+ export function isRegistered(clazzOrName) {
291
+ return defaultContainer.has(clazzOrName)
292
+ }
293
+
294
+ /**
295
+ * Validate that all provided injection tokens are registered.
296
+ * Throws an error with details about missing registrations.
297
+ * Useful for fail-fast validation at application startup.
298
+ *
299
+ * @param {...(string|Function)} tokens Classes or names to validate
300
+ * @throws {Error} If any token is not registered
301
+ * @example
302
+ * // At app startup:
303
+ * validateRegistrations(UserService, AuthService, 'databaseConnection')
304
+ */
305
+ export function validateRegistrations(...tokens) {
306
+ const missing = tokens.filter(token => !defaultContainer.has(token))
307
+ if (missing.length > 0) {
308
+ const names = missing.map(t => typeof t === 'string' ? t : t.name).join(', ')
309
+ throw new Error(
310
+ `Missing registrations: [${names}]. ` +
311
+ `Ensure these classes are decorated with @Singleton() or @Factory() before use.`
312
+ )
254
313
  }
255
- return instance
256
314
  }
315
+
316
+ // Export Container class for advanced use cases (e.g., isolated containers)
317
+ export {Container}
318
+
319
+ // Export createProxy for advanced proxy use cases
320
+ export {createProxy} from './src/proxy.js'
package/package.json CHANGED
@@ -1,34 +1,73 @@
1
1
  {
2
2
  "name": "decorator-dependency-injection",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "A simple library for dependency injection using decorators",
5
5
  "author": "Ravi Gairola <mallox@pyxzl.net>",
6
6
  "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/mallocator/decorator-dependency-injection.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/mallocator/decorator-dependency-injection/issues"
13
+ },
14
+ "homepage": "https://github.com/mallocator/decorator-dependency-injection#readme",
7
15
  "keywords": [
8
16
  "dependency-injection",
9
17
  "di",
10
18
  "decorators",
11
- "mocking"
19
+ "mocking",
20
+ "singleton",
21
+ "factory"
12
22
  ],
23
+ "engines": {
24
+ "node": ">=18.0.0"
25
+ },
13
26
  "main": "index.js",
27
+ "types": "index.d.ts",
14
28
  "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./index.d.ts",
32
+ "import": "./index.js"
33
+ }
34
+ },
15
35
  "scripts": {
16
36
  "start": "babel-node index.js",
17
- "test": "jest"
37
+ "test": "jest --no-watchman",
38
+ "test:coverage": "jest --no-watchman --coverage",
39
+ "typecheck": "tsc test/types.check.ts --noEmit --esModuleInterop --skipLibCheck",
40
+ "lint": "eslint .",
41
+ "lint:fix": "eslint . --fix"
18
42
  },
19
43
  "jest": {
20
44
  "transform": {
21
45
  "^.+\\.js$": "babel-jest"
22
- }
46
+ },
47
+ "coverageThreshold": {
48
+ "global": {
49
+ "branches": 80,
50
+ "functions": 80,
51
+ "lines": 80,
52
+ "statements": 80
53
+ }
54
+ },
55
+ "collectCoverageFrom": [
56
+ "index.js",
57
+ "src/**/*.js"
58
+ ]
23
59
  },
24
60
  "devDependencies": {
25
61
  "@babel/cli": "^7.26.4",
26
62
  "@babel/core": "^7.26.9",
63
+ "@babel/eslint-parser": "^7.28.6",
27
64
  "@babel/plugin-proposal-decorators": "^7.25.9",
28
- "@babel/polyfill": "^7.12.1",
29
65
  "@babel/preset-env": "^7.26.9",
30
66
  "@babel/register": "^7.25.9",
67
+ "@eslint/js": "^9.39.2",
31
68
  "@types/jest": "^29.5.14",
32
- "jest": "^29.7.0"
69
+ "eslint": "^9.39.2",
70
+ "jest": "^29.7.0",
71
+ "typescript": "^5.9.3"
33
72
  }
34
- }
73
+ }