@zudojs/container 1.0.0 → 1.1.0
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/README.md +17 -1
- package/dist/containerCore/containerCore.core.d.ts +7 -0
- package/dist/containerCore/containerCore.core.js +19 -4
- package/dist/containerCore/containerCore.scope.d.ts +4 -1
- package/dist/containerCore/containerCore.scope.js +13 -3
- package/dist/containerLifecycle/containerLifecycle.core.js +4 -2
- package/dist/containerResolution/containerResolution.core.d.ts +9 -0
- package/dist/containerResolution/containerResolution.core.js +56 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -73,6 +73,12 @@ not capture a shorter-lived dependency.
|
|
|
73
73
|
`options.scope` you pass — a value provider can only ever return the one
|
|
74
74
|
instance you gave it.
|
|
75
75
|
|
|
76
|
+
A `useExisting` alias never becomes a second owner of its target's instance:
|
|
77
|
+
a `SINGLETON` or `SCOPED` target is disposed exactly once by whoever created
|
|
78
|
+
it, and a `SCOPED` alias of a `SINGLETON` leaves the singleton alive when the
|
|
79
|
+
scope is disposed. Only a `TRANSIENT` target captured by a cached alias is
|
|
80
|
+
tracked (and disposed) through the alias.
|
|
81
|
+
|
|
76
82
|
Tracking covers **every** `SINGLETON`/`SCOPED` instance the container
|
|
77
83
|
creates — including ones created transitively as dependencies of another
|
|
78
84
|
resolution — not just the value returned by the `resolve()` call.
|
|
@@ -141,7 +147,13 @@ which case the later registration wins. Use `container.replace()` to
|
|
|
141
147
|
intentionally swap a registration.
|
|
142
148
|
|
|
143
149
|
Replacing or removing a registration **evicts and disposes** its cached
|
|
144
|
-
singleton
|
|
150
|
+
singleton — and evicts any cached `useExisting` alias of it — so the next
|
|
151
|
+
`resolve()` of either token uses the new provider.
|
|
152
|
+
|
|
153
|
+
`DuplicateRegistrationError`, `CircularDependencyError`,
|
|
154
|
+
`RegistrationNotFoundError` and `ProviderResolutionError` are defined in
|
|
155
|
+
`@zudojs/errors` and re-exported from this package, so `instanceof` checks
|
|
156
|
+
need only one import.
|
|
145
157
|
|
|
146
158
|
## Resolution options
|
|
147
159
|
|
|
@@ -201,6 +213,10 @@ await container.dispose();
|
|
|
201
213
|
- Disposal is terminal: the container is marked disposed even if some
|
|
202
214
|
instances fail to dispose, and every failure is reported in the thrown
|
|
203
215
|
`AggregateError`. `dispose()` is idempotent; any use after disposal throws.
|
|
216
|
+
The container counts as disposed from the moment `dispose()` is called —
|
|
217
|
+
a `resolve()` racing the disposal throws rather than creating an instance
|
|
218
|
+
nobody will clean up — and concurrent `dispose()` calls all settle when
|
|
219
|
+
the one disposal finishes.
|
|
204
220
|
- `clearSingletons()` evicts and disposes cached singletons without
|
|
205
221
|
disposing the container.
|
|
206
222
|
|
|
@@ -114,8 +114,15 @@ export declare class Container implements ContainerLike {
|
|
|
114
114
|
* Disposal is terminal: the container is marked disposed even when some
|
|
115
115
|
* instances fail to dispose; every failure is reported in the thrown
|
|
116
116
|
* AggregateError. Idempotent.
|
|
117
|
+
*
|
|
118
|
+
* The container is marked disposed *before* any cleanup runs, so a
|
|
119
|
+
* `resolve()` racing the disposal throws instead of creating a singleton
|
|
120
|
+
* that would be tracked after the disposal snapshot and then silently
|
|
121
|
+
* dropped. Concurrent `dispose()` calls share the in-flight disposal and
|
|
122
|
+
* settle only when it has finished.
|
|
117
123
|
*/
|
|
118
124
|
dispose(): Promise<void>;
|
|
125
|
+
private runDispose;
|
|
119
126
|
get resolutionOptions(): {
|
|
120
127
|
autoRegisterClasses: boolean;
|
|
121
128
|
detectCircularDependencies: boolean;
|
|
@@ -30,6 +30,7 @@ export class Container {
|
|
|
30
30
|
#liveScopes = new Set();
|
|
31
31
|
#started = false;
|
|
32
32
|
#disposed = false;
|
|
33
|
+
#disposing;
|
|
33
34
|
constructor(options = {}) {
|
|
34
35
|
this.options = resolveContainerOptions(options);
|
|
35
36
|
this.name = this.options.name;
|
|
@@ -221,10 +222,26 @@ export class Container {
|
|
|
221
222
|
* Disposal is terminal: the container is marked disposed even when some
|
|
222
223
|
* instances fail to dispose; every failure is reported in the thrown
|
|
223
224
|
* AggregateError. Idempotent.
|
|
225
|
+
*
|
|
226
|
+
* The container is marked disposed *before* any cleanup runs, so a
|
|
227
|
+
* `resolve()` racing the disposal throws instead of creating a singleton
|
|
228
|
+
* that would be tracked after the disposal snapshot and then silently
|
|
229
|
+
* dropped. Concurrent `dispose()` calls share the in-flight disposal and
|
|
230
|
+
* settle only when it has finished.
|
|
224
231
|
*/
|
|
225
|
-
|
|
232
|
+
dispose() {
|
|
233
|
+
if (this.#disposing)
|
|
234
|
+
return this.#disposing;
|
|
226
235
|
if (this.#disposed)
|
|
227
|
-
return;
|
|
236
|
+
return Promise.resolve();
|
|
237
|
+
this.#disposed = true;
|
|
238
|
+
this.#started = false;
|
|
239
|
+
this.#disposing = this.runDispose().finally(() => {
|
|
240
|
+
this.#disposing = undefined;
|
|
241
|
+
});
|
|
242
|
+
return this.#disposing;
|
|
243
|
+
}
|
|
244
|
+
async runDispose() {
|
|
228
245
|
const failures = [];
|
|
229
246
|
for (const scope of [...this.#liveScopes]) {
|
|
230
247
|
try {
|
|
@@ -245,8 +262,6 @@ export class Container {
|
|
|
245
262
|
}
|
|
246
263
|
this.#lifecycle.shutdown();
|
|
247
264
|
this.#resolver.clearSingletonCache();
|
|
248
|
-
this.#disposed = true;
|
|
249
|
-
this.#started = false;
|
|
250
265
|
if (failures.length > 0)
|
|
251
266
|
throw new AggregateError(failures, `Container "${this.name}" was disposed, but ${failures.length} cleanup step(s) failed.`);
|
|
252
267
|
}
|
|
@@ -20,6 +20,7 @@ import type { RegistrationToken, ResolvedTokens } from "../containerRegistration
|
|
|
20
20
|
import type { ContainerScopeOptions, ContainerLike } from "./containerCore.type.js";
|
|
21
21
|
export declare class ContainerScopeContext {
|
|
22
22
|
private disposed;
|
|
23
|
+
private disposing;
|
|
23
24
|
private readonly cache;
|
|
24
25
|
private readonly lifecycle;
|
|
25
26
|
private readonly container;
|
|
@@ -70,9 +71,11 @@ export declare class ContainerScopeContext {
|
|
|
70
71
|
* from its parent. Container-owned singletons are not touched. Idempotent.
|
|
71
72
|
*
|
|
72
73
|
* Every failure is collected; the scope is marked disposed regardless and
|
|
73
|
-
* an AggregateError listing the failures is thrown afterwards.
|
|
74
|
+
* an AggregateError listing the failures is thrown afterwards. Concurrent
|
|
75
|
+
* callers share the in-flight disposal rather than returning early.
|
|
74
76
|
*/
|
|
75
77
|
dispose(): Promise<void>;
|
|
78
|
+
private runDispose;
|
|
76
79
|
/**
|
|
77
80
|
* Returns whether the scope has been disposed.
|
|
78
81
|
*/
|
|
@@ -20,6 +20,7 @@ import { ContainerLifecycle, ContainerLifecycleOwner, } from "../containerLifecy
|
|
|
20
20
|
import { ContainerScope } from "../containerScope/containerScope.type.js";
|
|
21
21
|
export class ContainerScopeContext {
|
|
22
22
|
disposed = false;
|
|
23
|
+
disposing;
|
|
23
24
|
cache;
|
|
24
25
|
lifecycle;
|
|
25
26
|
container;
|
|
@@ -94,12 +95,21 @@ export class ContainerScopeContext {
|
|
|
94
95
|
* from its parent. Container-owned singletons are not touched. Idempotent.
|
|
95
96
|
*
|
|
96
97
|
* Every failure is collected; the scope is marked disposed regardless and
|
|
97
|
-
* an AggregateError listing the failures is thrown afterwards.
|
|
98
|
+
* an AggregateError listing the failures is thrown afterwards. Concurrent
|
|
99
|
+
* callers share the in-flight disposal rather than returning early.
|
|
98
100
|
*/
|
|
99
|
-
|
|
101
|
+
dispose() {
|
|
102
|
+
if (this.disposing)
|
|
103
|
+
return this.disposing;
|
|
100
104
|
if (this.disposed)
|
|
101
|
-
return;
|
|
105
|
+
return Promise.resolve();
|
|
102
106
|
this.disposed = true;
|
|
107
|
+
this.disposing = this.runDispose().finally(() => {
|
|
108
|
+
this.disposing = undefined;
|
|
109
|
+
});
|
|
110
|
+
return this.disposing;
|
|
111
|
+
}
|
|
112
|
+
async runDispose() {
|
|
103
113
|
const failures = [];
|
|
104
114
|
for (const child of [...this.children].reverse()) {
|
|
105
115
|
try {
|
|
@@ -120,6 +120,10 @@ export class ContainerLifecycle {
|
|
|
120
120
|
async dispose(owner) {
|
|
121
121
|
if (this.disposed)
|
|
122
122
|
return;
|
|
123
|
+
// Mark before the first await: an instance tracked while disposal is in
|
|
124
|
+
// flight would be dropped by `shutdown()` without ever being disposed.
|
|
125
|
+
if (owner === undefined)
|
|
126
|
+
this.disposed = true;
|
|
123
127
|
const tracked = [...this.instances.values()].reverse();
|
|
124
128
|
const selected = owner
|
|
125
129
|
? tracked.filter((entry) => entry.owner === owner)
|
|
@@ -141,8 +145,6 @@ export class ContainerLifecycle {
|
|
|
141
145
|
break;
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
|
-
if (owner === undefined)
|
|
145
|
-
this.disposed = true;
|
|
146
148
|
if (errors.length > 0)
|
|
147
149
|
throw new ContainerDisposalError(errors, failedTokens);
|
|
148
150
|
}
|
|
@@ -54,5 +54,14 @@ export declare class ContainerResolver {
|
|
|
54
54
|
canResolve<T>(token: RegistrationToken<T>, autoRegisterClasses?: boolean): boolean;
|
|
55
55
|
private handleRegistryChange;
|
|
56
56
|
private evictSingleton;
|
|
57
|
+
/**
|
|
58
|
+
* Evicts every cached singleton whose `useExisting` chain ends at `target`.
|
|
59
|
+
*
|
|
60
|
+
* A cached alias holds the target's instance under its own token, so
|
|
61
|
+
* evicting the target alone left the alias serving the old — by now
|
|
62
|
+
* disposed — instance after `replace()`/`remove()`.
|
|
63
|
+
*/
|
|
64
|
+
private evictAliasesOf;
|
|
65
|
+
private aliasChainReaches;
|
|
57
66
|
}
|
|
58
67
|
//# sourceMappingURL=containerResolution.core.d.ts.map
|
|
@@ -140,8 +140,9 @@ export class ContainerResolver {
|
|
|
140
140
|
state.path.push(token);
|
|
141
141
|
state.pathSet.add(token);
|
|
142
142
|
let value;
|
|
143
|
+
let owned;
|
|
143
144
|
try {
|
|
144
|
-
value = this.createInstance(registration, state, nextAncestor);
|
|
145
|
+
({ value, owned } = this.createInstance(registration, state, nextAncestor));
|
|
145
146
|
}
|
|
146
147
|
finally {
|
|
147
148
|
state.path.pop();
|
|
@@ -159,7 +160,13 @@ export class ContainerResolver {
|
|
|
159
160
|
fromCache: false,
|
|
160
161
|
path: currentPath,
|
|
161
162
|
};
|
|
162
|
-
|
|
163
|
+
// A `useExisting` alias of a cached (SINGLETON/SCOPED) target does not
|
|
164
|
+
// own the instance it hands out — the target's own creation already
|
|
165
|
+
// reported it. Reporting it again registered a second owner for the same
|
|
166
|
+
// object: the container disposed it twice, and a SCOPED alias let a
|
|
167
|
+
// scope dispose a container-owned singleton.
|
|
168
|
+
if (owned)
|
|
169
|
+
state.onInstanceCreated?.(result);
|
|
163
170
|
return result;
|
|
164
171
|
}
|
|
165
172
|
createInstance(registration, state, singletonAncestor) {
|
|
@@ -167,7 +174,7 @@ export class ContainerResolver {
|
|
|
167
174
|
const token = getRegistrationToken(registration);
|
|
168
175
|
try {
|
|
169
176
|
if (isValueProvider(provider))
|
|
170
|
-
return provider.useValue;
|
|
177
|
+
return { value: provider.useValue, owned: true };
|
|
171
178
|
if (isExistingProvider(provider)) {
|
|
172
179
|
const target = unwrapToken(provider.useExisting);
|
|
173
180
|
if (!this.registry.has(target) &&
|
|
@@ -175,8 +182,13 @@ export class ContainerResolver {
|
|
|
175
182
|
throw new Error(`useExisting target "${describeToken(target)}" for token ` +
|
|
176
183
|
`"${describeToken(token)}" is not registered.`);
|
|
177
184
|
}
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
const resolved = this.resolveInternal(target, state, singletonAncestor);
|
|
186
|
+
// Only a TRANSIENT target has no owner of its own; a cached alias
|
|
187
|
+
// of it is the one place the instance can be tracked.
|
|
188
|
+
return {
|
|
189
|
+
value: resolved.value,
|
|
190
|
+
owned: resolved.scope === Scope.TRANSIENT,
|
|
191
|
+
};
|
|
180
192
|
}
|
|
181
193
|
if (isFactoryProvider(provider)) {
|
|
182
194
|
const deps = provider.inject ?? [];
|
|
@@ -185,14 +197,14 @@ export class ContainerResolver {
|
|
|
185
197
|
const produced = provider.useFactory(...args);
|
|
186
198
|
if (registration.scope !== Scope.TRANSIENT && isPromiseLike(produced))
|
|
187
199
|
throw new AsyncProviderError(describeToken(token), registration.scope);
|
|
188
|
-
return produced;
|
|
200
|
+
return { value: produced, owned: true };
|
|
189
201
|
}
|
|
190
202
|
if (isClassProvider(provider)) {
|
|
191
203
|
const deps = provider.inject ?? [];
|
|
192
204
|
const args = deps.map((d) => this.resolveInternal(unwrapToken(d), state, singletonAncestor)
|
|
193
205
|
.value);
|
|
194
206
|
const ctor = provider.useClass;
|
|
195
|
-
return new ctor(...args);
|
|
207
|
+
return { value: new ctor(...args), owned: true };
|
|
196
208
|
}
|
|
197
209
|
throw new Error("Unsupported container provider.");
|
|
198
210
|
}
|
|
@@ -239,7 +251,9 @@ export class ContainerResolver {
|
|
|
239
251
|
switch (event.operation) {
|
|
240
252
|
case RegistryOperation.REPLACE:
|
|
241
253
|
case RegistryOperation.REMOVE: {
|
|
242
|
-
|
|
254
|
+
const token = unwrapToken(event.token);
|
|
255
|
+
this.evictSingleton(token);
|
|
256
|
+
this.evictAliasesOf(token);
|
|
243
257
|
break;
|
|
244
258
|
}
|
|
245
259
|
case RegistryOperation.CLEAR:
|
|
@@ -258,6 +272,40 @@ export class ContainerResolver {
|
|
|
258
272
|
this.singletonCache.delete(token);
|
|
259
273
|
this.onSingletonEvicted?.(token);
|
|
260
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Evicts every cached singleton whose `useExisting` chain ends at `target`.
|
|
277
|
+
*
|
|
278
|
+
* A cached alias holds the target's instance under its own token, so
|
|
279
|
+
* evicting the target alone left the alias serving the old — by now
|
|
280
|
+
* disposed — instance after `replace()`/`remove()`.
|
|
281
|
+
*/
|
|
282
|
+
evictAliasesOf(target) {
|
|
283
|
+
for (const cached of [...this.singletonCache.keys()]) {
|
|
284
|
+
if (cached === target)
|
|
285
|
+
continue;
|
|
286
|
+
if (this.aliasChainReaches(cached, target))
|
|
287
|
+
this.evictSingleton(cached);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
aliasChainReaches(token, target) {
|
|
291
|
+
const visited = new Set();
|
|
292
|
+
let current = token;
|
|
293
|
+
for (;;) {
|
|
294
|
+
const registration = this.registry.get(current);
|
|
295
|
+
if (!registration)
|
|
296
|
+
return false;
|
|
297
|
+
const provider = normalizeProvider(registration.provider);
|
|
298
|
+
if (!isExistingProvider(provider))
|
|
299
|
+
return false;
|
|
300
|
+
const next = unwrapToken(provider.useExisting);
|
|
301
|
+
if (next === target)
|
|
302
|
+
return true;
|
|
303
|
+
if (visited.has(next))
|
|
304
|
+
return false;
|
|
305
|
+
visited.add(next);
|
|
306
|
+
current = next;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
261
309
|
}
|
|
262
310
|
function isPromiseLike(value) {
|
|
263
311
|
return (typeof value === "object" &&
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,5 @@ export * from "./containerRegistry/index.js";
|
|
|
12
12
|
export * from "./containerResolution/index.js";
|
|
13
13
|
export * from "./containerScope/index.js";
|
|
14
14
|
export * from "./containerToken/index.js";
|
|
15
|
+
export { CircularDependencyError, DuplicateRegistrationError, RegistrationNotFoundError, ProviderResolutionError, } from "@zudojs/errors";
|
|
15
16
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -12,4 +12,7 @@ export * from "./containerRegistry/index.js";
|
|
|
12
12
|
export * from "./containerResolution/index.js";
|
|
13
13
|
export * from "./containerScope/index.js";
|
|
14
14
|
export * from "./containerToken/index.js";
|
|
15
|
+
// Error classes the container throws but that live in @zudojs/errors,
|
|
16
|
+
// re-exported so `instanceof` checks need only this package.
|
|
17
|
+
export { CircularDependencyError, DuplicateRegistrationError, RegistrationNotFoundError, ProviderResolutionError, } from "@zudojs/errors";
|
|
15
18
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/container",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Token-based dependency injection container for managing application dependencies and service lifetimes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,13 +18,17 @@
|
|
|
18
18
|
"!dist/.tsbuildinfo"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@zudojs/errors": "1.0.
|
|
21
|
+
"@zudojs/errors": "1.0.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"typescript": "7.0.2",
|
|
25
25
|
"vitest": "^4.1.11"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Oluwayemi Oyinlola",
|
|
30
|
+
"url": "https://github.com/oyinlola-tech"
|
|
31
|
+
},
|
|
28
32
|
"publishConfig": {
|
|
29
33
|
"access": "public"
|
|
30
34
|
},
|