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.
@@ -0,0 +1,211 @@
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
+ /** @type {boolean} Enable debug logging */
22
+ #debug = false
23
+
24
+ /**
25
+ * Enable or disable debug logging.
26
+ * When enabled, logs when instances are created.
27
+ * @param {boolean} enabled Whether to enable debug mode
28
+ */
29
+ setDebug(enabled) {
30
+ this.#debug = enabled
31
+ }
32
+
33
+ /**
34
+ * Log a debug message if debug mode is enabled.
35
+ * @param {string} message The message to log
36
+ * @private
37
+ */
38
+ #log(message) {
39
+ if (this.#debug) {
40
+ console.log(`[DI] ${message}`)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Register a class as a singleton.
46
+ * @param {Function} clazz The class constructor
47
+ * @param {string} [name] Optional name key
48
+ */
49
+ registerSingleton(clazz, name) {
50
+ this.#register(clazz, 'singleton', name)
51
+ }
52
+
53
+ /**
54
+ * Register a class as a factory.
55
+ * @param {Function} clazz The class constructor
56
+ * @param {string} [name] Optional name key
57
+ */
58
+ registerFactory(clazz, name) {
59
+ this.#register(clazz, 'factory', name)
60
+ }
61
+
62
+ /**
63
+ * Internal registration logic.
64
+ * @param {Function} clazz The class constructor
65
+ * @param {'singleton'|'factory'} type The registration type
66
+ * @param {string} [name] Optional name key
67
+ * @private
68
+ */
69
+ #register(clazz, type, name) {
70
+ const key = name ?? clazz
71
+ if (this.#instances.has(key)) {
72
+ throw new Error(
73
+ 'A different class is already registered under this name. ' +
74
+ 'This may be a circular dependency. Try using @InjectLazy'
75
+ )
76
+ }
77
+ this.#instances.set(key, {clazz, type})
78
+ this.#log(`Registered ${type}: ${name || clazz.name}`)
79
+ }
80
+
81
+ /**
82
+ * Get the context for a given class or name.
83
+ * @param {string|Function} clazzOrName The class or name to look up
84
+ * @returns {InstanceContext}
85
+ * @throws {Error} If the context is not found
86
+ */
87
+ getContext(clazzOrName) {
88
+ if (this.#instances.has(clazzOrName)) {
89
+ return this.#instances.get(clazzOrName)
90
+ }
91
+ const available = Array.from(this.#instances.keys())
92
+ .map(k => typeof k === 'string' ? k : k.name)
93
+ .join(', ')
94
+ throw new Error(
95
+ `Cannot find injection source for "${clazzOrName?.name || clazzOrName}". ` +
96
+ `Available: [${available}]`
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Check if a class or name is registered.
102
+ * @param {string|Function} clazzOrName The class or name to check
103
+ * @returns {boolean}
104
+ */
105
+ has(clazzOrName) {
106
+ return this.#instances.has(clazzOrName)
107
+ }
108
+
109
+ /**
110
+ * Get or create an instance based on the context.
111
+ * @param {InstanceContext} instanceContext The instance context
112
+ * @param {Array} params Constructor parameters
113
+ * @returns {Object} The instance
114
+ */
115
+ getInstance(instanceContext, params) {
116
+ if (instanceContext.type === 'singleton' && !instanceContext.originalClazz && instanceContext.instance) {
117
+ this.#log(`Returning cached singleton: ${instanceContext.clazz.name}`)
118
+ return instanceContext.instance
119
+ }
120
+
121
+ let instance
122
+ try {
123
+ this.#log(`Creating ${instanceContext.type}: ${instanceContext.clazz.name}`)
124
+ instance = new instanceContext.clazz(...params)
125
+ } catch (err) {
126
+ if (err instanceof RangeError) {
127
+ throw new Error(
128
+ `Circular dependency detected for ${instanceContext.clazz.name || instanceContext.clazz}. ` +
129
+ `Use @InjectLazy to break the cycle.`
130
+ )
131
+ }
132
+ throw err
133
+ }
134
+
135
+ if (instanceContext.proxy && instanceContext.originalClazz) {
136
+ const originalInstance = new instanceContext.originalClazz(...params)
137
+ instance = createProxy(instance, originalInstance)
138
+ }
139
+
140
+ if (instanceContext.type === 'singleton') {
141
+ instanceContext.instance = instance
142
+ }
143
+
144
+ return instance
145
+ }
146
+
147
+ /**
148
+ * Register a mock for an existing class.
149
+ * @param {string|Function} targetClazzOrName The class or name to mock
150
+ * @param {Function} mockClazz The mock class
151
+ * @param {boolean} [useProxy=false] Whether to proxy unmocked methods to original
152
+ */
153
+ registerMock(targetClazzOrName, mockClazz, useProxy = false) {
154
+ const instanceContext = this.getContext(targetClazzOrName)
155
+ if (instanceContext.originalClazz) {
156
+ throw new Error('Mock already defined, reset before mocking again')
157
+ }
158
+ instanceContext.originalClazz = instanceContext.clazz
159
+ instanceContext.proxy = useProxy
160
+ instanceContext.clazz = mockClazz
161
+ const targetName = typeof targetClazzOrName === 'string' ? targetClazzOrName : targetClazzOrName.name
162
+ this.#log(`Mocked ${targetName} with ${mockClazz.name}${useProxy ? ' (proxy)' : ''}`)
163
+ }
164
+
165
+ /**
166
+ * Reset a specific mock to its original class.
167
+ * @param {string|Function} clazzOrName The class or name to reset
168
+ * @throws {Error} If the class or name is not registered
169
+ */
170
+ resetMock(clazzOrName) {
171
+ this.#restoreOriginal(this.#instances.get(clazzOrName), clazzOrName)
172
+ }
173
+
174
+ /**
175
+ * Reset all mocks to their original classes.
176
+ */
177
+ resetAllMocks() {
178
+ for (const instanceContext of this.#instances.values()) {
179
+ this.#restoreOriginal(instanceContext)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Clear all registered instances and mocks.
185
+ * Useful for test isolation.
186
+ */
187
+ clear() {
188
+ this.#instances.clear()
189
+ }
190
+
191
+ /**
192
+ * Internal function to restore an instance context to its original.
193
+ * @param {InstanceContext} instanceContext The instance context to reset
194
+ * @param {string|Function} [clazzOrName] Optional identifier for error messages
195
+ * @throws {Error} If instanceContext is null or undefined
196
+ * @private
197
+ */
198
+ #restoreOriginal(instanceContext, clazzOrName) {
199
+ if (!instanceContext) {
200
+ const name = clazzOrName?.name || clazzOrName || 'unknown'
201
+ throw new Error(`Cannot reset mock for "${name}": not registered`)
202
+ }
203
+ if (instanceContext.originalClazz) {
204
+ instanceContext.clazz = instanceContext.originalClazz
205
+ delete instanceContext.instance
206
+ delete instanceContext.originalClazz
207
+ delete instanceContext.originalInstance
208
+ delete instanceContext.proxy
209
+ }
210
+ }
211
+ }
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
+ }
@@ -1,129 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- push:
5
- branches: [ "main" ]
6
- pull_request:
7
- branches: [ "main" ]
8
-
9
- jobs:
10
- build:
11
- runs-on: ubuntu-latest
12
- strategy:
13
- matrix:
14
- node-version: [22.x, 23.x]
15
- # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
16
- steps:
17
- - uses: actions/checkout@v4
18
- - name: Use Node.js ${{ matrix.node-version }}
19
- uses: actions/setup-node@v4
20
- with:
21
- node-version: ${{ matrix.node-version }}
22
- cache: 'npm'
23
- - run: npm ci
24
- - run: npm run build --if-present
25
- - run: npm test
26
-
27
- release:
28
- needs: build
29
- runs-on: ubuntu-latest
30
- permissions:
31
- contents: write
32
- steps:
33
- - name: Checkout Repository
34
- uses: actions/checkout@v4
35
-
36
- - name: Install Node.js
37
- uses: actions/setup-node@v4
38
- with:
39
- node-version: '22'
40
-
41
- - name: Get package.json version
42
- id: package_version
43
- run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
44
-
45
- - name: Check if tag exists
46
- id: check_tag
47
- run: |
48
- TAG_EXISTS=$(git ls-remote --tags origin "v${VERSION}" | wc -l)
49
- echo "TAG_EXISTS=${TAG_EXISTS}" >> $GITHUB_ENV
50
-
51
- - name: Get commit messages
52
- id: commit_messages
53
- run: |
54
- COMMITS=$(git log --pretty=format:"%h - %s" $(git describe --tags --abbrev=0 @^)..@ | while read -r hash msg; do echo "[${hash}](https://github.com/${{ github.repository }}/commit/${hash}) - ${msg}"; done)
55
- echo "COMMITS=${COMMITS}" >> $GITHUB_ENV
56
-
57
- - name: Create Tag and Release
58
- uses: actions/github-script@v7
59
- with:
60
- script: |
61
- const version = process.env.VERSION
62
- const tag = `v${version}`
63
- const commits = process.env.COMMITS
64
- const { data: tags } = await github.rest.repos.listTags({
65
- owner: context.repo.owner,
66
- repo: context.repo.repo,
67
- })
68
-
69
- if (!tags.find(t => t.name === tag)) {
70
- await github.rest.git.createRef({
71
- owner: context.repo.owner,
72
- repo: context.repo.repo,
73
- ref: `refs/tags/${tag}`,
74
- sha: context.sha,
75
- })
76
-
77
- await github.rest.repos.createRelease({
78
- owner: context.repo.owner,
79
- repo: context.repo.repo,
80
- tag_name: tag,
81
- name: `Release ${tag}`,
82
- body: `Automated release of version ${tag}\n\nCommits:\n${commits}`,
83
- draft: false,
84
- prerelease: false,
85
- })
86
- core.notice(`Release ${tag} created.`)
87
- } else {
88
- core.notice(`Release ${tag} already exists. No new release created.`)
89
- }
90
-
91
- publish:
92
- needs: release
93
- runs-on: ubuntu-latest
94
- steps:
95
- - name: Checkout Repository
96
- uses: actions/checkout@v4
97
-
98
- - name: Install Node.js
99
- uses: actions/setup-node@v4
100
- with:
101
- node-version: '22'
102
- registry-url: 'https://registry.npmjs.org/'
103
-
104
- - name: Get package.json version
105
- id: package_version
106
- run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
107
-
108
- - name: Check if version exists on npm
109
- id: check_npm_version
110
- run: |
111
- if npm show decorator-dependency-injection@${{ env.VERSION }} > /dev/null 2>&1; then
112
- echo "VERSION_EXISTS=true" >> $GITHUB_ENV
113
- else
114
- echo "VERSION_EXISTS=false" >> $GITHUB_ENV
115
- fi
116
-
117
- - name: Publish to npm
118
- if: env.VERSION_EXISTS == 'false'
119
- run: |
120
- npm publish --access public
121
- echo "::notice::Published new version ${{ env.VERSION }} to npm."
122
- env:
123
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
124
-
125
- - name: Notice Publish Status
126
- if: env.VERSION_EXISTS == 'true'
127
- run: echo "::notice::Version ${{ env.VERSION }} already exists on npm. No new version published."
128
-
129
-
package/babel.config.json DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "presets": ["@babel/preset-env"],
3
- "plugins": [
4
- ["@babel/plugin-proposal-decorators", { "version": "2023-11" }]
5
- ]
6
- }
@@ -1,309 +0,0 @@
1
- import {Factory, Inject, resetMocks, Singleton} from '../index.js'
2
-
3
- describe('Injection via fields', () => {
4
- @Singleton()
5
- class TestSingleton {
6
- static calls = 0
7
-
8
- constructor() {
9
- TestSingleton.calls++
10
- }
11
- }
12
-
13
- it('should inject singleton', () => {
14
- class TestInjection {
15
- @Inject(TestSingleton) testSingleton
16
-
17
- constructor() {
18
- expect(this.testSingleton).toBeInstanceOf(TestSingleton)
19
- expect(TestSingleton.calls).toBe(1)
20
- }
21
- }
22
-
23
- class TestInjection2 {
24
- @Inject(TestSingleton) testSingleton
25
-
26
- constructor() {
27
- expect(this.testSingleton).toBeInstanceOf(TestSingleton)
28
- expect(TestSingleton.calls).toBe(1)
29
- }
30
- }
31
-
32
- new TestInjection()
33
- new TestInjection2()
34
- })
35
-
36
- @Factory()
37
- class TestFactory {
38
- static calls = 0
39
- params
40
-
41
- @Inject(TestSingleton) testSingleton
42
-
43
- constructor(...params) {
44
- TestFactory.calls++
45
- this.params = params
46
- }
47
- }
48
-
49
- afterEach(() => {
50
- TestFactory.calls = 0
51
- TestSingleton.calls = 0
52
- resetMocks()
53
- })
54
-
55
- it('should inject factory', () => {
56
- class TestInjectionFactory {
57
- @Inject(TestFactory) testFactory
58
-
59
- constructor() {
60
- expect(this.testFactory).toBeInstanceOf(TestFactory)
61
- expect(TestFactory.calls).toBe(1)
62
- }
63
- }
64
-
65
- class TestInjectionFactory2 {
66
- @Inject(TestFactory) testFactory
67
-
68
- constructor() {
69
- expect(this.testFactory).toBeInstanceOf(TestFactory)
70
- expect(TestFactory.calls).toBe(2)
71
- }
72
- }
73
-
74
- const result = new TestInjectionFactory()
75
- new TestInjectionFactory2()
76
- expect(result.testFactory.testSingleton).toBeInstanceOf(TestSingleton)
77
- })
78
-
79
- it('should inject factory with parameters', () => {
80
- class TestInjectionFactoryParams {
81
- @Inject(TestFactory, 'param1', 'param2') testFactory
82
-
83
- constructor() {
84
- expect(this.testFactory).toBeInstanceOf(TestFactory)
85
- expect(this.testFactory.params).toEqual(['param1', 'param2'])
86
- }
87
- }
88
-
89
- new TestInjectionFactoryParams()
90
- })
91
-
92
- it('should cache factory instance on repeated accesses', () => {
93
- class TestRepeatedFactoryAccess {
94
- @Inject(TestFactory) testFactory
95
-
96
- constructor() {
97
- const instance1 = this.testFactory
98
- const instance2 = this.testFactory
99
- expect(instance1).toBe(instance2)
100
- }
101
- }
102
-
103
- new TestRepeatedFactoryAccess()
104
- })
105
-
106
- it('should create distinct factory instances for different fields in the same object', () => {
107
- class TestMultipleFactoryInjection {
108
- @Inject(TestFactory) testFactory1
109
- @Inject(TestFactory) testFactory2
110
-
111
- constructor() {
112
- // Access both properties to trigger initialization.
113
- const one = this.testFactory1
114
- const two = this.testFactory2
115
- expect(one).not.toBe(two)
116
- }
117
- }
118
-
119
- new TestMultipleFactoryInjection()
120
- })
121
-
122
- it('should inject the same singleton instance for different fields in the same object', () => {
123
- class TestMultipleSingletonInjection {
124
- @Inject(TestSingleton) testSingleton1
125
- @Inject(TestSingleton) testSingleton2
126
-
127
- constructor() {
128
- // Access both properties to trigger initialization.
129
- const one = this.testSingleton1
130
- const two = this.testSingleton2
131
- expect(one).toBe(two)
132
- }
133
- }
134
-
135
- new TestMultipleSingletonInjection()
136
- })
137
-
138
- @Singleton('named')
139
- class NamedSingleton {
140
- static calls = 0
141
-
142
- constructor() {
143
- NamedSingleton.calls++
144
- }
145
- }
146
-
147
- it('should inject named singleton', () => {
148
- class TestInjectionNamedSingleton {
149
- @Inject('named') namedSingleton
150
-
151
- constructor() {
152
- expect(this.namedSingleton).toBeInstanceOf(NamedSingleton)
153
- expect(NamedSingleton.calls).toBe(1)
154
- }
155
- }
156
-
157
- class TestInjectionNamedSingleton2 {
158
- @Inject('named') namedSingleton
159
-
160
- constructor() {
161
- expect(this.namedSingleton).toBeInstanceOf(NamedSingleton)
162
- expect(NamedSingleton.calls).toBe(1)
163
- }
164
- }
165
-
166
- new TestInjectionNamedSingleton()
167
- new TestInjectionNamedSingleton2()
168
- })
169
-
170
- @Factory('named2')
171
- class NamedFactory {
172
- static calls = 0
173
- params
174
-
175
- constructor(...params) {
176
- NamedFactory.calls++
177
- this.params = params
178
- }
179
- }
180
-
181
- it('should inject named factory', () => {
182
- class TestInjectionNamedFactory {
183
- @Inject('named2') namedFactory
184
-
185
- constructor() {
186
- expect(this.namedFactory).toBeInstanceOf(NamedFactory)
187
- expect(NamedFactory.calls).toBe(1)
188
- }
189
- }
190
-
191
- class TestInjectionNamedFactory2 {
192
- @Inject('named2') namedFactory
193
-
194
- constructor() {
195
- expect(this.namedFactory).toBeInstanceOf(NamedFactory)
196
- expect(NamedFactory.calls).toBe(2)
197
- }
198
- }
199
-
200
- const result = new TestInjectionNamedFactory()
201
- new TestInjectionNamedFactory2()
202
- expect(result.namedFactory.params).toEqual([])
203
- })
204
-
205
- it('should cache named factory instance on repeated accesses', () => {
206
- class TestRepeatedNamedFactoryAccess {
207
- @Inject('named2') namedFactory
208
-
209
- constructor() {
210
- const instance1 = this.namedFactory
211
- const instance2 = this.namedFactory
212
- expect(instance1).toBe(instance2)
213
- }
214
- }
215
-
216
- new TestRepeatedNamedFactoryAccess()
217
- })
218
-
219
- it('should throw if @Inject is applied to a method', () => {
220
- expect(() => {
221
- // noinspection JSUnusedLocalSymbols
222
- class BadInjection {
223
- @Inject('something')
224
- someMethod() {
225
- }
226
- }
227
- }).toThrow()
228
- })
229
-
230
- it('should handle circular dependencies gracefully', () => {
231
- @Singleton()
232
- class A {
233
- @Inject('B') b
234
- }
235
-
236
- @Singleton('B')
237
- class B {
238
- @Inject(A) a
239
- }
240
-
241
- expect(() => new A()).toThrow(/Circular dependency detected.*@InjectLazy/)
242
- })
243
-
244
- it('should throw if decorator is used on non-class object', () => {
245
- expect(() => {
246
- const obj = {}
247
- Inject('something')(obj, 'field')
248
- }).toThrow()
249
- })
250
-
251
- it('should throw a helpful error for eager circular dependencies', () => {
252
- @Factory()
253
- class A2 {
254
- @Inject('B2') b
255
- }
256
-
257
- @Singleton('B2')
258
- class B2 {
259
- @Inject(A2) a
260
- }
261
-
262
- expect(() => new A2()).toThrow(/Circular dependency detected.*@InjectLazy/)
263
- })
264
-
265
- it('should inject into symbol-named fields', () => {
266
- const sym = Symbol('sym')
267
-
268
- @Singleton()
269
- class S {
270
- }
271
-
272
- class Test {
273
- @Inject(S) [sym]
274
- }
275
-
276
- const t = new Test()
277
- expect(t[sym]).toBeInstanceOf(S)
278
- })
279
-
280
- it('should not leak injected properties to prototype', () => {
281
- @Singleton()
282
- class S {
283
- }
284
-
285
- class Test {
286
- @Inject(S) dep
287
- }
288
-
289
- // noinspection JSUnusedLocalSymbols
290
- const t = new Test()
291
- expect(Object.prototype.hasOwnProperty.call(Test.prototype, 'dep')).toBe(false)
292
- })
293
-
294
- it('should handle undefined/null/complex params in factory', () => {
295
- @Factory()
296
- class F {
297
- constructor(...params) {
298
- this.params = params
299
- }
300
- }
301
-
302
- class Test {
303
- @Inject(F, undefined, null, {a: 1}) dep
304
- }
305
-
306
- const t = new Test()
307
- expect(t.dep.params).toEqual([undefined, null, {a: 1}])
308
- })
309
- })