decorator-dependency-injection 1.0.2 → 1.0.4

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,37 +1,37 @@
1
1
  /**
2
- * @typedef {Object} InstanceContext
3
- * @property {string} type The type of the instance, either 'singleton' or 'factory'
4
- * @property {Class} clazz The class of the instance
5
- * @property {Class} [originalClazz] The original class if it is a mock
6
- * @property {Object} [instance] The instance if it is a singleton
7
- * @property {Object} [originalInstance] The original instance if it is a mock
8
- * @property {boolean} [proxy=false] If true, the mock if the injection instance will be a proxy to the original class
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
13
 
14
14
  /**
15
15
  * Register a class as a singleton. If a name is provided, it will be used as the key in the singleton map.
16
16
  * Singleton instances only ever have one instance created via the @Inject decorator.
17
17
  *
18
18
  * @param {string} [name] The name of the singleton. If not provided, the class will be used as the key.
19
- * @return {(function(*, *): void)|*}
19
+ * @returns {(function(Function, {kind: string}): void)}
20
20
  * @example @Singleton() class MySingleton {}
21
21
  * @example @Singleton('customName') class MySingleton {}
22
22
  * @throws {Error} If the injection target is not a class
23
- * @throws {Error} If a singleton with the same name is already defined
24
- * @throws {Error} If a factory with the same name is already defined
23
+ * @throws {Error} If a singleton or factory with the same name is already defined
24
+ * @throws {Error} If the target is not a class constructor
25
25
  */
26
26
  export function Singleton(name) {
27
27
  return function (clazz, context) {
28
- if (context.kind !== "class") {
28
+ if (context.kind !== 'class') {
29
29
  throw new Error('Invalid injection target')
30
30
  }
31
- if (instances.has(name ?? clazz)) {
32
- throw new Error('Instance with that name or class already instantiated')
31
+ if (typeof clazz !== 'function' || !clazz.prototype) {
32
+ throw new Error('Target must be a class constructor')
33
33
  }
34
- instances.set(name ?? clazz, { clazz, type: 'singleton' })
34
+ defaultContainer.registerSingleton(clazz, name)
35
35
  }
36
36
  }
37
37
 
@@ -40,22 +40,22 @@ export function Singleton(name) {
40
40
  * Factory instances are created via the @Inject decorator. Each call to the factory will create a new instance.
41
41
  *
42
42
  * @param {string} [name] The name of the factory. If not provided, the class will be used as the key.
43
- * @return {(function(*, *): void)|*}
43
+ * @returns {(function(Function, {kind: string}): void)}
44
44
  * @example @Factory() class MyFactory {}
45
45
  * @example @Factory('customName') class MyFactory {}
46
46
  * @throws {Error} If the injection target is not a class
47
- * @throws {Error} If a factory with the same name is already defined
48
- * @throws {Error} If a singleton with the same name is already defined
47
+ * @throws {Error} If a factory or singleton with the same name is already defined
48
+ * @throws {Error} If the target is not a class constructor
49
49
  */
50
50
  export function Factory(name) {
51
51
  return function (clazz, context) {
52
- if (context.kind !== "class") {
52
+ if (context.kind !== 'class') {
53
53
  throw new Error('Invalid injection target')
54
54
  }
55
- if (instances.has(name ?? clazz)) {
56
- throw new Error('Instance with that name or class already instantiated')
55
+ if (typeof clazz !== 'function' || !clazz.prototype) {
56
+ throw new Error('Target must be a class constructor')
57
57
  }
58
- instances.set(name ?? clazz, { clazz, type: 'factory' })
58
+ defaultContainer.registerFactory(clazz, name)
59
59
  }
60
60
  }
61
61
 
@@ -63,95 +63,85 @@ export function Factory(name) {
63
63
  * Inject a singleton or factory instance into a class field. You can also provide parameters to the constructor.
64
64
  * If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
65
65
  *
66
- * @param {string|Class} clazzOrName The singleton or factory class or name
67
- * @param {*} params Parameters to pass to the constructor. Recommended to use only with factories.
68
- * @return {(function(*): void)|*}
66
+ * @param {string|Function} clazzOrName The singleton or factory class or name
67
+ * @param {...*} params Parameters to pass to the constructor. Recommended to use only with factories.
68
+ * @returns {(function(*, {kind: string, name: string}): function(): Object)}
69
69
  * @example @Inject(MySingleton) mySingleton
70
70
  * @example @Inject("myCustomName") myFactory
71
71
  * @throws {Error} If the injection target is not a field
72
72
  * @throws {Error} If the injected field is assigned a value
73
73
  */
74
74
  export function Inject(clazzOrName, ...params) {
75
- return function(initialValue, context) {
76
- if (context.kind === "field") {
77
- return function(initialValue) {
78
- if (initialValue) {
79
- throw new Error('Cannot assign value to injected field')
80
- }
81
- const instanceContext = getContext(clazzOrName)
82
-
83
- if (instanceContext.instance) {
84
- return instanceContext.instance
85
- }
86
-
87
- const instance = new instanceContext.clazz(...params)
88
-
89
- if (instanceContext.type === 'singleton') {
90
- if (instanceContext.originalClazz && instanceContext.proxy) {
91
- instanceContext.instance = getProxy(instance, new instanceContext.originalClazz(...params))
92
- } else {
93
- instanceContext.instance = instance
94
- }
95
- return instanceContext.instance
96
- }
97
-
98
- if (instanceContext.type === 'factory') {
99
- if (instanceContext.originalClazz && instanceContext.proxy) {
100
- return getProxy(instance, new instanceContext.originalClazz(...params))
101
- } else {
102
- return instance
103
- }
104
- }
105
-
106
- throw new Error('Unexpected injection type')
107
- }
108
- } else {
75
+ return function (_, context) {
76
+ if (context.kind !== 'field') {
109
77
  throw new Error('Invalid injection target')
110
78
  }
79
+ return function (initialValue) {
80
+ if (initialValue) {
81
+ throw new Error('Cannot assign value to injected field')
82
+ }
83
+ const instanceContext = defaultContainer.getContext(clazzOrName)
84
+ return defaultContainer.getInstance(instanceContext, params)
85
+ }
111
86
  }
112
87
  }
113
88
 
114
- function getProxy(mock, original) {
115
- return new Proxy(mock, {
116
- get(target, prop, receiver) {
117
- if (prop in target) {
118
- return Reflect.get(target, prop, receiver)
119
- }
120
- return Reflect.get(original, prop, receiver)
89
+ /**
90
+ * Inject a singleton or factory instance lazily into a class field. You can also provide parameters to the constructor.
91
+ * If the instance is a singleton, it will only be created once with the first set of parameters it encounters.
92
+ *
93
+ * @param {string|Function} clazzOrName The singleton or factory class or name
94
+ * @param {...*} params Parameters to pass to the constructor. Recommended to use only with factories.
95
+ * @returns {(function(*, {kind: string, name: string, addInitializer: Function}): void)}
96
+ * @example @InjectLazy(MySingleton) mySingleton
97
+ * @example @InjectLazy("myCustomName") myFactory
98
+ * @throws {Error} If the injection target is not a field
99
+ * @throws {Error} If the injected field is assigned a value
100
+ */
101
+ export function InjectLazy(clazzOrName, ...params) {
102
+ const cache = new WeakMap()
103
+ return (_, context) => {
104
+ if (context.kind !== 'field') {
105
+ throw new Error('Invalid injection target')
121
106
  }
122
- })
107
+ context.addInitializer(function () {
108
+ Object.defineProperty(this, context.name, {
109
+ get() {
110
+ if (!cache.has(this)) {
111
+ const instanceContext = defaultContainer.getContext(clazzOrName)
112
+ const value = defaultContainer.getInstance(instanceContext, params)
113
+ cache.set(this, value)
114
+ }
115
+ return cache.get(this)
116
+ },
117
+ set() {
118
+ throw new Error(`Cannot assign value to lazy-injected field "${context.name}"`)
119
+ },
120
+ configurable: true,
121
+ enumerable: true
122
+ })
123
+ })
124
+ }
123
125
  }
124
126
 
125
127
  /**
126
128
  * Mark a class as a mock. This will replace the class with a mock instance when injected.
127
- * @param {string|Class} mockedClazzOrName The singleton or factory class or name to be mocked
128
- * @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.
129
- * @return {(function(*, *): void)|*}
129
+ *
130
+ * @param {string|Function} mockedClazzOrName The singleton or factory class or name to be mocked
131
+ * @param {boolean} [proxy=false] If true, the mock will proxy to the original class.
132
+ * Any methods not defined in the mock will be called on the original class.
133
+ * @returns {(function(Function, {kind: string}): void)}
130
134
  * @example @Mock(MySingleton) class MyMock {}
131
- * @example @Mock("myCustomName") class MyMock {}
135
+ * @example @Mock("myCustomName", true) class MyMock {}
132
136
  * @throws {Error} If the injection target is not a class
133
137
  * @throws {Error} If the injection source is not found
134
138
  */
135
139
  export function Mock(mockedClazzOrName, proxy = false) {
136
- return function(clazz, context) {
137
- if (context.kind !== "class") {
140
+ return function (clazz, context) {
141
+ if (context.kind !== 'class') {
138
142
  throw new Error('Invalid injection target')
139
143
  }
140
- const instanceContext = getContext(mockedClazzOrName)
141
- if (instanceContext.originalClazz) {
142
- throw new Error('Mock already defined, reset before mocking again')
143
- }
144
- instanceContext.originalClazz = instanceContext.clazz
145
- instanceContext.proxy = proxy
146
- instanceContext.clazz = clazz
147
- }
148
- }
149
-
150
- function getContext(mockedClazzOrName) {
151
- if (instances.has(mockedClazzOrName)) {
152
- return instances.get(mockedClazzOrName)
153
- } else {
154
- throw new Error('Cannot find injection source with the provided name')
144
+ defaultContainer.registerMock(mockedClazzOrName, clazz, proxy)
155
145
  }
156
146
  }
157
147
 
@@ -159,32 +149,38 @@ function getContext(mockedClazzOrName) {
159
149
  * Reset all mocks to their original classes.
160
150
  */
161
151
  export function resetMocks() {
162
- for (const instanceContext of instances.values()) {
163
- reset(instanceContext)
164
- }
152
+ defaultContainer.resetAllMocks()
165
153
  }
166
154
 
167
155
  /**
168
156
  * Reset a specific mock to its original class.
169
- * @param {string|Class} clazzOrName The singleton or factory class or name to reset
157
+ *
158
+ * @param {string|Function} clazzOrName The singleton or factory class or name to reset
170
159
  */
171
160
  export function resetMock(clazzOrName) {
172
- reset(getContext(clazzOrName))
161
+ defaultContainer.resetMock(clazzOrName)
173
162
  }
174
163
 
175
164
  /**
176
- * Internal function to reset an instance context to its original.
177
- * @param {InstanceContext} instanceContext The instance context to reset
178
- * @private
165
+ * Clear all registered instances and mocks from the container.
166
+ * Useful for complete test isolation between test suites.
179
167
  */
180
- function reset(instanceContext) {
181
- if (!instanceContext) {
182
- throw new Error('Cannot find injection source with the provided name')
183
- }
184
- if (instanceContext.originalClazz) {
185
- instanceContext.clazz = instanceContext.originalClazz
186
- instanceContext.instance = instanceContext.originalInstance
187
- delete instanceContext.originalClazz
188
- delete instanceContext.originalInstance
189
- }
168
+ export function clearContainer() {
169
+ defaultContainer.clear()
170
+ }
171
+
172
+ /**
173
+ * Get the default container instance.
174
+ * Useful for advanced use cases or testing the container itself.
175
+ *
176
+ * @returns {Container} The default container
177
+ */
178
+ export function getContainer() {
179
+ return defaultContainer
190
180
  }
181
+
182
+ // Export Container class for advanced use cases (e.g., isolated containers)
183
+ export {Container}
184
+
185
+ // Export createProxy for advanced proxy use cases
186
+ 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.2",
3
+ "version": "1.0.4",
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
+ }
@@ -0,0 +1,184 @@
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.
9
+ */
10
+
11
+ import {createProxy} from './proxy.js'
12
+
13
+ /**
14
+ * A dependency injection container that manages singleton and factory instances.
15
+ * Supports mocking for testing purposes.
16
+ */
17
+ export class Container {
18
+ /** @type {Map<string|Function, InstanceContext>} */
19
+ #instances = new Map()
20
+
21
+ /**
22
+ * Register a class as a singleton.
23
+ * @param {Function} clazz The class constructor
24
+ * @param {string} [name] Optional name key
25
+ */
26
+ registerSingleton(clazz, name) {
27
+ this.#register(clazz, 'singleton', name)
28
+ }
29
+
30
+ /**
31
+ * Register a class as a factory.
32
+ * @param {Function} clazz The class constructor
33
+ * @param {string} [name] Optional name key
34
+ */
35
+ registerFactory(clazz, name) {
36
+ this.#register(clazz, 'factory', name)
37
+ }
38
+
39
+ /**
40
+ * Internal registration logic.
41
+ * @param {Function} clazz The class constructor
42
+ * @param {'singleton'|'factory'} type The registration type
43
+ * @param {string} [name] Optional name key
44
+ * @private
45
+ */
46
+ #register(clazz, type, name) {
47
+ const key = name ?? clazz
48
+ if (this.#instances.has(key)) {
49
+ throw new Error(
50
+ 'A different class is already registered under this name. ' +
51
+ 'This may be a circular dependency. Try using @InjectLazy'
52
+ )
53
+ }
54
+ this.#instances.set(key, {clazz, type})
55
+ }
56
+
57
+ /**
58
+ * Get the context for a given class or name.
59
+ * @param {string|Function} clazzOrName The class or name to look up
60
+ * @returns {InstanceContext}
61
+ * @throws {Error} If the context is not found
62
+ */
63
+ getContext(clazzOrName) {
64
+ if (this.#instances.has(clazzOrName)) {
65
+ return this.#instances.get(clazzOrName)
66
+ }
67
+ const available = Array.from(this.#instances.keys())
68
+ .map(k => typeof k === 'string' ? k : k.name)
69
+ .join(', ')
70
+ throw new Error(
71
+ `Cannot find injection source for "${clazzOrName?.name || clazzOrName}". ` +
72
+ `Available: [${available}]`
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Check if a class or name is registered.
78
+ * @param {string|Function} clazzOrName The class or name to check
79
+ * @returns {boolean}
80
+ */
81
+ has(clazzOrName) {
82
+ return this.#instances.has(clazzOrName)
83
+ }
84
+
85
+ /**
86
+ * Get or create an instance based on the context.
87
+ * @param {InstanceContext} instanceContext The instance context
88
+ * @param {Array} params Constructor parameters
89
+ * @returns {Object} The instance
90
+ */
91
+ getInstance(instanceContext, params) {
92
+ if (instanceContext.type === 'singleton' && !instanceContext.originalClazz && instanceContext.instance) {
93
+ return instanceContext.instance
94
+ }
95
+
96
+ let instance
97
+ try {
98
+ instance = new instanceContext.clazz(...params)
99
+ } catch (err) {
100
+ if (err instanceof RangeError) {
101
+ throw new Error(
102
+ `Circular dependency detected for ${instanceContext.clazz.name || instanceContext.clazz}. ` +
103
+ `Use @InjectLazy to break the cycle.`
104
+ )
105
+ }
106
+ throw err
107
+ }
108
+
109
+ if (instanceContext.proxy && instanceContext.originalClazz) {
110
+ const originalInstance = new instanceContext.originalClazz(...params)
111
+ instance = createProxy(instance, originalInstance)
112
+ }
113
+
114
+ if (instanceContext.type === 'singleton') {
115
+ instanceContext.instance = instance
116
+ }
117
+
118
+ return instance
119
+ }
120
+
121
+ /**
122
+ * Register a mock for an existing class.
123
+ * @param {string|Function} targetClazzOrName The class or name to mock
124
+ * @param {Function} mockClazz The mock class
125
+ * @param {boolean} [useProxy=false] Whether to proxy unmocked methods to original
126
+ */
127
+ registerMock(targetClazzOrName, mockClazz, useProxy = false) {
128
+ const instanceContext = this.getContext(targetClazzOrName)
129
+ if (instanceContext.originalClazz) {
130
+ throw new Error('Mock already defined, reset before mocking again')
131
+ }
132
+ instanceContext.originalClazz = instanceContext.clazz
133
+ instanceContext.proxy = useProxy
134
+ instanceContext.clazz = mockClazz
135
+ }
136
+
137
+ /**
138
+ * Reset a specific mock to its original class.
139
+ * @param {string|Function} clazzOrName The class or name to reset
140
+ * @throws {Error} If the class or name is not registered
141
+ */
142
+ resetMock(clazzOrName) {
143
+ const key = typeof clazzOrName === 'string' ? clazzOrName : clazzOrName
144
+ this.#restoreOriginal(this.#instances.get(key), clazzOrName)
145
+ }
146
+
147
+ /**
148
+ * Reset all mocks to their original classes.
149
+ */
150
+ resetAllMocks() {
151
+ for (const instanceContext of this.#instances.values()) {
152
+ this.#restoreOriginal(instanceContext)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Clear all registered instances and mocks.
158
+ * Useful for test isolation.
159
+ */
160
+ clear() {
161
+ this.#instances.clear()
162
+ }
163
+
164
+ /**
165
+ * Internal function to restore an instance context to its original.
166
+ * @param {InstanceContext} instanceContext The instance context to reset
167
+ * @param {string|Function} [clazzOrName] Optional identifier for error messages
168
+ * @throws {Error} If instanceContext is null or undefined
169
+ * @private
170
+ */
171
+ #restoreOriginal(instanceContext, clazzOrName) {
172
+ if (!instanceContext) {
173
+ const name = clazzOrName?.name || clazzOrName || 'unknown'
174
+ throw new Error(`Cannot reset mock for "${name}": not registered`)
175
+ }
176
+ if (instanceContext.originalClazz) {
177
+ instanceContext.clazz = instanceContext.originalClazz
178
+ delete instanceContext.instance
179
+ delete instanceContext.originalClazz
180
+ delete instanceContext.originalInstance
181
+ delete instanceContext.proxy
182
+ }
183
+ }
184
+ }
package/src/proxy.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Create a proxy that delegates to the mock first, then falls back to the original.
3
+ * This allows partial mocking where only specific methods are overridden.
4
+ *
5
+ * @param {Object} mock The mock instance
6
+ * @param {Object} original The original instance to fall back to
7
+ * @returns {Proxy} A proxy that delegates appropriately
8
+ */
9
+ export function createProxy(mock, original) {
10
+ return new Proxy(mock, {
11
+ get(target, prop, receiver) {
12
+ if (prop in target) {
13
+ return Reflect.get(target, prop, receiver)
14
+ }
15
+ return Reflect.get(original, prop, original)
16
+ },
17
+
18
+ set(target, prop, value, receiver) {
19
+ if (prop in target) {
20
+ return Reflect.set(target, prop, value, receiver)
21
+ }
22
+ return Reflect.set(original, prop, value, original)
23
+ },
24
+
25
+ has(target, prop) {
26
+ return prop in target || prop in original
27
+ },
28
+
29
+ ownKeys(target) {
30
+ const mockKeys = Reflect.ownKeys(target)
31
+ const originalKeys = Reflect.ownKeys(original)
32
+ return [...new Set([...mockKeys, ...originalKeys])]
33
+ },
34
+
35
+ getOwnPropertyDescriptor(target, prop) {
36
+ if (prop in target) {
37
+ return Reflect.getOwnPropertyDescriptor(target, prop)
38
+ }
39
+ return Reflect.getOwnPropertyDescriptor(original, prop)
40
+ }
41
+ })
42
+ }