@zudojs/container 1.0.0 → 1.1.1
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 +33 -2
- package/dist/containerCore/containerCore.core.d.ts +7 -0
- package/dist/containerCore/containerCore.core.js +24 -4
- package/dist/containerCore/containerCore.scope.d.ts +11 -1
- package/dist/containerCore/containerCore.scope.js +26 -3
- package/dist/containerLifecycle/containerLifecycle.core.js +4 -2
- package/dist/containerResolution/containerResolution.core.d.ts +19 -1
- package/dist/containerResolution/containerResolution.core.js +82 -9
- package/dist/containerResolution/containerResolution.dependents.d.ts +33 -0
- package/dist/containerResolution/containerResolution.dependents.js +66 -0
- package/dist/containerResolution/containerResolution.type.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Token-based dependency injection container for managing application dependencies and service lifetimes.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-container](https://zudojs.oyinlola.site/docs/packages-container) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-container.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
7
13
|
```bash
|
|
@@ -71,7 +77,16 @@ not capture a shorter-lived dependency.
|
|
|
71
77
|
|
|
72
78
|
`registerValue()` always registers as `SINGLETON`, overriding any
|
|
73
79
|
`options.scope` you pass — a value provider can only ever return the one
|
|
74
|
-
instance you gave it.
|
|
80
|
+
instance you gave it. The container did not create that value, so it
|
|
81
|
+
never disposes it: a pool or logger shared between containers, or owned
|
|
82
|
+
by the host, stays open when a container is disposed. Register a factory
|
|
83
|
+
instead when the container should own the instance.
|
|
84
|
+
|
|
85
|
+
A `useExisting` alias never becomes a second owner of its target's instance:
|
|
86
|
+
a `SINGLETON` or `SCOPED` target is disposed exactly once by whoever created
|
|
87
|
+
it, and a `SCOPED` alias of a `SINGLETON` leaves the singleton alive when the
|
|
88
|
+
scope is disposed. Only a `TRANSIENT` target captured by a cached alias is
|
|
89
|
+
tracked (and disposed) through the alias.
|
|
75
90
|
|
|
76
91
|
Tracking covers **every** `SINGLETON`/`SCOPED` instance the container
|
|
77
92
|
creates — including ones created transitively as dependencies of another
|
|
@@ -141,7 +156,18 @@ which case the later registration wins. Use `container.replace()` to
|
|
|
141
156
|
intentionally swap a registration.
|
|
142
157
|
|
|
143
158
|
Replacing or removing a registration **evicts and disposes** its cached
|
|
144
|
-
singleton
|
|
159
|
+
singleton — and evicts any cached `useExisting` alias of it — so the next
|
|
160
|
+
`resolve()` of either token uses the new provider. Eviction cascades: every
|
|
161
|
+
cached singleton that was built on the replaced token (directly or through
|
|
162
|
+
a `TRANSIENT` in between) is evicted and disposed too, so consumers are
|
|
163
|
+
rebuilt against the new instance instead of keeping the old, disposed one.
|
|
164
|
+
Live scopes drop (and dispose) their cached `SCOPED` copies of the replaced
|
|
165
|
+
token and of its consumers.
|
|
166
|
+
|
|
167
|
+
`DuplicateRegistrationError`, `CircularDependencyError`,
|
|
168
|
+
`RegistrationNotFoundError` and `ProviderResolutionError` are defined in
|
|
169
|
+
`@zudojs/errors` and re-exported from this package, so `instanceof` checks
|
|
170
|
+
need only one import.
|
|
145
171
|
|
|
146
172
|
## Resolution options
|
|
147
173
|
|
|
@@ -196,11 +222,16 @@ await container.dispose();
|
|
|
196
222
|
have a `dispose()` method or implement `Symbol.dispose` /
|
|
197
223
|
`Symbol.asyncDispose`.
|
|
198
224
|
- `TRANSIENT` instances are never tracked; dispose them yourself.
|
|
225
|
+
- `registerValue()` values are never disposed by the container.
|
|
199
226
|
- With `autoDispose: false`, `dispose()` releases tracked references
|
|
200
227
|
**without disposing them** — disposal becomes your responsibility.
|
|
201
228
|
- Disposal is terminal: the container is marked disposed even if some
|
|
202
229
|
instances fail to dispose, and every failure is reported in the thrown
|
|
203
230
|
`AggregateError`. `dispose()` is idempotent; any use after disposal throws.
|
|
231
|
+
The container counts as disposed from the moment `dispose()` is called —
|
|
232
|
+
a `resolve()` racing the disposal throws rather than creating an instance
|
|
233
|
+
nobody will clean up — and concurrent `dispose()` calls all settle when
|
|
234
|
+
the one disposal finishes.
|
|
204
235
|
- `clearSingletons()` evicts and disposes cached singletons without
|
|
205
236
|
disposing the container.
|
|
206
237
|
|
|
@@ -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;
|
|
@@ -43,6 +44,11 @@ export class Container {
|
|
|
43
44
|
void this.#lifecycle.disposeInstance(token).catch(() => {
|
|
44
45
|
/* see clearSingletons()/dispose() for error-surfacing disposal */
|
|
45
46
|
});
|
|
47
|
+
}, (token) => {
|
|
48
|
+
// Live scopes would otherwise keep serving a replaced SCOPED
|
|
49
|
+
// instance, or one built on a replaced dependency.
|
|
50
|
+
for (const scope of this.#liveScopes)
|
|
51
|
+
scope.evictCached(token);
|
|
46
52
|
});
|
|
47
53
|
}
|
|
48
54
|
/**
|
|
@@ -221,10 +227,26 @@ export class Container {
|
|
|
221
227
|
* Disposal is terminal: the container is marked disposed even when some
|
|
222
228
|
* instances fail to dispose; every failure is reported in the thrown
|
|
223
229
|
* AggregateError. Idempotent.
|
|
230
|
+
*
|
|
231
|
+
* The container is marked disposed *before* any cleanup runs, so a
|
|
232
|
+
* `resolve()` racing the disposal throws instead of creating a singleton
|
|
233
|
+
* that would be tracked after the disposal snapshot and then silently
|
|
234
|
+
* dropped. Concurrent `dispose()` calls share the in-flight disposal and
|
|
235
|
+
* settle only when it has finished.
|
|
224
236
|
*/
|
|
225
|
-
|
|
237
|
+
dispose() {
|
|
238
|
+
if (this.#disposing)
|
|
239
|
+
return this.#disposing;
|
|
226
240
|
if (this.#disposed)
|
|
227
|
-
return;
|
|
241
|
+
return Promise.resolve();
|
|
242
|
+
this.#disposed = true;
|
|
243
|
+
this.#started = false;
|
|
244
|
+
this.#disposing = this.runDispose().finally(() => {
|
|
245
|
+
this.#disposing = undefined;
|
|
246
|
+
});
|
|
247
|
+
return this.#disposing;
|
|
248
|
+
}
|
|
249
|
+
async runDispose() {
|
|
228
250
|
const failures = [];
|
|
229
251
|
for (const scope of [...this.#liveScopes]) {
|
|
230
252
|
try {
|
|
@@ -245,8 +267,6 @@ export class Container {
|
|
|
245
267
|
}
|
|
246
268
|
this.#lifecycle.shutdown();
|
|
247
269
|
this.#resolver.clearSingletonCache();
|
|
248
|
-
this.#disposed = true;
|
|
249
|
-
this.#started = false;
|
|
250
270
|
if (failures.length > 0)
|
|
251
271
|
throw new AggregateError(failures, `Container "${this.name}" was disposed, but ${failures.length} cleanup step(s) failed.`);
|
|
252
272
|
}
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
* ancestor (scope or container) is disposed.
|
|
18
18
|
*/
|
|
19
19
|
import type { RegistrationToken, ResolvedTokens } from "../containerRegistration/containerRegistration.core.js";
|
|
20
|
+
import type { Token } from "../containerToken/containerToken.type.js";
|
|
20
21
|
import type { ContainerScopeOptions, ContainerLike } from "./containerCore.type.js";
|
|
21
22
|
export declare class ContainerScopeContext {
|
|
22
23
|
private disposed;
|
|
24
|
+
private disposing;
|
|
23
25
|
private readonly cache;
|
|
24
26
|
private readonly lifecycle;
|
|
25
27
|
private readonly container;
|
|
@@ -70,9 +72,11 @@ export declare class ContainerScopeContext {
|
|
|
70
72
|
* from its parent. Container-owned singletons are not touched. Idempotent.
|
|
71
73
|
*
|
|
72
74
|
* Every failure is collected; the scope is marked disposed regardless and
|
|
73
|
-
* an AggregateError listing the failures is thrown afterwards.
|
|
75
|
+
* an AggregateError listing the failures is thrown afterwards. Concurrent
|
|
76
|
+
* callers share the in-flight disposal rather than returning early.
|
|
74
77
|
*/
|
|
75
78
|
dispose(): Promise<void>;
|
|
79
|
+
private runDispose;
|
|
76
80
|
/**
|
|
77
81
|
* Returns whether the scope has been disposed.
|
|
78
82
|
*/
|
|
@@ -86,6 +90,12 @@ export declare class ContainerScopeContext {
|
|
|
86
90
|
* Returns the container that owns the whole scope tree.
|
|
87
91
|
*/
|
|
88
92
|
getContainer(): ContainerLike;
|
|
93
|
+
/**
|
|
94
|
+
* @internal Drops a cached SCOPED instance whose registration (or a
|
|
95
|
+
* dependency of it) was replaced or removed, disposing it best-effort,
|
|
96
|
+
* in this scope and every child scope.
|
|
97
|
+
*/
|
|
98
|
+
evictCached(token: Token): void;
|
|
89
99
|
/** @internal Detaches a disposed child scope. */
|
|
90
100
|
releaseChild(scope: ContainerScopeContext): void;
|
|
91
101
|
/**
|
|
@@ -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 {
|
|
@@ -146,6 +156,19 @@ export class ContainerScopeContext {
|
|
|
146
156
|
getContainer() {
|
|
147
157
|
return this.container;
|
|
148
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* @internal Drops a cached SCOPED instance whose registration (or a
|
|
161
|
+
* dependency of it) was replaced or removed, disposing it best-effort,
|
|
162
|
+
* in this scope and every child scope.
|
|
163
|
+
*/
|
|
164
|
+
evictCached(token) {
|
|
165
|
+
if (this.disposed)
|
|
166
|
+
return;
|
|
167
|
+
this.cache.delete?.(token);
|
|
168
|
+
void this.lifecycle.disposeInstance(token).catch(() => undefined);
|
|
169
|
+
for (const child of this.children)
|
|
170
|
+
child.evictCached(token);
|
|
171
|
+
}
|
|
149
172
|
/** @internal Detaches a disposed child scope. */
|
|
150
173
|
releaseChild(scope) {
|
|
151
174
|
this.children.delete(scope);
|
|
@@ -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
|
}
|
|
@@ -32,7 +32,16 @@ export declare class ContainerResolver {
|
|
|
32
32
|
private readonly registry;
|
|
33
33
|
private readonly singletonCache;
|
|
34
34
|
private readonly onSingletonEvicted;
|
|
35
|
-
|
|
35
|
+
private readonly onTokenInvalidated;
|
|
36
|
+
private readonly dependents;
|
|
37
|
+
/**
|
|
38
|
+
* @param onSingletonEvicted Called for each evicted cached singleton so
|
|
39
|
+
* the owner can dispose it.
|
|
40
|
+
* @param onTokenInvalidated Called for every token invalidated by a
|
|
41
|
+
* `replace()`/`remove()` (the token itself and each cached consumer),
|
|
42
|
+
* so owners of scope caches can drop and dispose their SCOPED copies.
|
|
43
|
+
*/
|
|
44
|
+
constructor(registry: ContainerRegistry, onSingletonEvicted?: (token: Token<unknown>) => void, onTokenInvalidated?: (token: Token<unknown>) => void);
|
|
36
45
|
resolve<T>(token: RegistrationToken<T>, options?: ResolutionOptions): T;
|
|
37
46
|
resolveDetailed<T>(token: RegistrationToken<T>, options?: ResolutionOptions): ResolutionResult<T>;
|
|
38
47
|
private resolveInternal;
|
|
@@ -54,5 +63,14 @@ export declare class ContainerResolver {
|
|
|
54
63
|
canResolve<T>(token: RegistrationToken<T>, autoRegisterClasses?: boolean): boolean;
|
|
55
64
|
private handleRegistryChange;
|
|
56
65
|
private evictSingleton;
|
|
66
|
+
/**
|
|
67
|
+
* Evicts every cached singleton whose `useExisting` chain ends at `target`.
|
|
68
|
+
*
|
|
69
|
+
* A cached alias holds the target's instance under its own token, so
|
|
70
|
+
* evicting the target alone left the alias serving the old — by now
|
|
71
|
+
* disposed — instance after `replace()`/`remove()`.
|
|
72
|
+
*/
|
|
73
|
+
private evictAliasesOf;
|
|
74
|
+
private aliasChainReaches;
|
|
57
75
|
}
|
|
58
76
|
//# sourceMappingURL=containerResolution.core.d.ts.map
|
|
@@ -32,6 +32,7 @@ import { unwrapToken } from "../containerToken/containerToken.type.js";
|
|
|
32
32
|
import { CircularDependencyError, ProviderResolutionError, RegistrationNotFoundError, } from "@zudojs/errors";
|
|
33
33
|
import { AsyncProviderError, CaptiveDependencyError, DependencyResolutionError, MaxResolutionDepthError, ScopedResolutionError, } from "./containerResolution.error.js";
|
|
34
34
|
import { describeToken } from "../containerToken/containerToken.type.js";
|
|
35
|
+
import { DependentIndex } from "./containerResolution.dependents.js";
|
|
35
36
|
/**
|
|
36
37
|
* Scope cache that falls back to its parent scope's cache for lookups while
|
|
37
38
|
* writing only to its own map. Nested scopes therefore see SCOPED instances
|
|
@@ -55,6 +56,9 @@ class ChainedResolutionCache {
|
|
|
55
56
|
set(token, value) {
|
|
56
57
|
this.#own.set(token, value);
|
|
57
58
|
}
|
|
59
|
+
delete(token) {
|
|
60
|
+
return this.#own.delete(token);
|
|
61
|
+
}
|
|
58
62
|
clear() {
|
|
59
63
|
this.#own.clear();
|
|
60
64
|
}
|
|
@@ -63,9 +67,19 @@ export class ContainerResolver {
|
|
|
63
67
|
registry;
|
|
64
68
|
singletonCache = new Map();
|
|
65
69
|
onSingletonEvicted;
|
|
66
|
-
|
|
70
|
+
onTokenInvalidated;
|
|
71
|
+
dependents = new DependentIndex();
|
|
72
|
+
/**
|
|
73
|
+
* @param onSingletonEvicted Called for each evicted cached singleton so
|
|
74
|
+
* the owner can dispose it.
|
|
75
|
+
* @param onTokenInvalidated Called for every token invalidated by a
|
|
76
|
+
* `replace()`/`remove()` (the token itself and each cached consumer),
|
|
77
|
+
* so owners of scope caches can drop and dispose their SCOPED copies.
|
|
78
|
+
*/
|
|
79
|
+
constructor(registry, onSingletonEvicted, onTokenInvalidated) {
|
|
67
80
|
this.registry = registry;
|
|
68
81
|
this.onSingletonEvicted = onSingletonEvicted;
|
|
82
|
+
this.onTokenInvalidated = onTokenInvalidated;
|
|
69
83
|
registry.subscribe((event) => this.handleRegistryChange(event));
|
|
70
84
|
}
|
|
71
85
|
resolve(token, options = {}) {
|
|
@@ -108,6 +122,7 @@ export class ContainerResolver {
|
|
|
108
122
|
}
|
|
109
123
|
}
|
|
110
124
|
const currentPath = [...state.path, token];
|
|
125
|
+
this.dependents.record(token, state.path, (t) => this.registry.get(t)?.scope);
|
|
111
126
|
if (registration.scope === Scope.SINGLETON) {
|
|
112
127
|
if (this.singletonCache.has(token)) {
|
|
113
128
|
return {
|
|
@@ -140,8 +155,9 @@ export class ContainerResolver {
|
|
|
140
155
|
state.path.push(token);
|
|
141
156
|
state.pathSet.add(token);
|
|
142
157
|
let value;
|
|
158
|
+
let owned;
|
|
143
159
|
try {
|
|
144
|
-
value = this.createInstance(registration, state, nextAncestor);
|
|
160
|
+
({ value, owned } = this.createInstance(registration, state, nextAncestor));
|
|
145
161
|
}
|
|
146
162
|
finally {
|
|
147
163
|
state.path.pop();
|
|
@@ -159,15 +175,24 @@ export class ContainerResolver {
|
|
|
159
175
|
fromCache: false,
|
|
160
176
|
path: currentPath,
|
|
161
177
|
};
|
|
162
|
-
|
|
178
|
+
// A `useExisting` alias of a cached (SINGLETON/SCOPED) target does not
|
|
179
|
+
// own the instance it hands out — the target's own creation already
|
|
180
|
+
// reported it. Reporting it again registered a second owner for the same
|
|
181
|
+
// object: the container disposed it twice, and a SCOPED alias let a
|
|
182
|
+
// scope dispose a container-owned singleton.
|
|
183
|
+
if (owned)
|
|
184
|
+
state.onInstanceCreated?.(result);
|
|
163
185
|
return result;
|
|
164
186
|
}
|
|
165
187
|
createInstance(registration, state, singletonAncestor) {
|
|
166
188
|
const provider = normalizeProvider(registration.provider);
|
|
167
189
|
const token = getRegistrationToken(registration);
|
|
168
190
|
try {
|
|
191
|
+
// A pre-built value belongs to whoever built it (often shared
|
|
192
|
+
// between containers); the container did not create it, so it
|
|
193
|
+
// must not dispose it.
|
|
169
194
|
if (isValueProvider(provider))
|
|
170
|
-
return provider.useValue;
|
|
195
|
+
return { value: provider.useValue, owned: false };
|
|
171
196
|
if (isExistingProvider(provider)) {
|
|
172
197
|
const target = unwrapToken(provider.useExisting);
|
|
173
198
|
if (!this.registry.has(target) &&
|
|
@@ -175,8 +200,13 @@ export class ContainerResolver {
|
|
|
175
200
|
throw new Error(`useExisting target "${describeToken(target)}" for token ` +
|
|
176
201
|
`"${describeToken(token)}" is not registered.`);
|
|
177
202
|
}
|
|
178
|
-
|
|
179
|
-
|
|
203
|
+
const resolved = this.resolveInternal(target, state, singletonAncestor);
|
|
204
|
+
// Only a TRANSIENT target has no owner of its own; a cached alias
|
|
205
|
+
// of it is the one place the instance can be tracked.
|
|
206
|
+
return {
|
|
207
|
+
value: resolved.value,
|
|
208
|
+
owned: resolved.scope === Scope.TRANSIENT,
|
|
209
|
+
};
|
|
180
210
|
}
|
|
181
211
|
if (isFactoryProvider(provider)) {
|
|
182
212
|
const deps = provider.inject ?? [];
|
|
@@ -185,14 +215,14 @@ export class ContainerResolver {
|
|
|
185
215
|
const produced = provider.useFactory(...args);
|
|
186
216
|
if (registration.scope !== Scope.TRANSIENT && isPromiseLike(produced))
|
|
187
217
|
throw new AsyncProviderError(describeToken(token), registration.scope);
|
|
188
|
-
return produced;
|
|
218
|
+
return { value: produced, owned: true };
|
|
189
219
|
}
|
|
190
220
|
if (isClassProvider(provider)) {
|
|
191
221
|
const deps = provider.inject ?? [];
|
|
192
222
|
const args = deps.map((d) => this.resolveInternal(unwrapToken(d), state, singletonAncestor)
|
|
193
223
|
.value);
|
|
194
224
|
const ctor = provider.useClass;
|
|
195
|
-
return new ctor(...args);
|
|
225
|
+
return { value: new ctor(...args), owned: true };
|
|
196
226
|
}
|
|
197
227
|
throw new Error("Unsupported container provider.");
|
|
198
228
|
}
|
|
@@ -239,13 +269,22 @@ export class ContainerResolver {
|
|
|
239
269
|
switch (event.operation) {
|
|
240
270
|
case RegistryOperation.REPLACE:
|
|
241
271
|
case RegistryOperation.REMOVE: {
|
|
242
|
-
|
|
272
|
+
const token = unwrapToken(event.token);
|
|
273
|
+
// Consumers first, so each is disposed before what it consumed.
|
|
274
|
+
for (const dependent of this.dependents.take(token)) {
|
|
275
|
+
this.evictSingleton(dependent);
|
|
276
|
+
this.onTokenInvalidated?.(dependent);
|
|
277
|
+
}
|
|
278
|
+
this.evictSingleton(token);
|
|
279
|
+
this.evictAliasesOf(token);
|
|
280
|
+
this.onTokenInvalidated?.(token);
|
|
243
281
|
break;
|
|
244
282
|
}
|
|
245
283
|
case RegistryOperation.CLEAR:
|
|
246
284
|
case RegistryOperation.RESTORE: {
|
|
247
285
|
for (const t of [...this.singletonCache.keys()])
|
|
248
286
|
this.evictSingleton(t);
|
|
287
|
+
this.dependents.clear();
|
|
249
288
|
break;
|
|
250
289
|
}
|
|
251
290
|
default:
|
|
@@ -258,6 +297,40 @@ export class ContainerResolver {
|
|
|
258
297
|
this.singletonCache.delete(token);
|
|
259
298
|
this.onSingletonEvicted?.(token);
|
|
260
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Evicts every cached singleton whose `useExisting` chain ends at `target`.
|
|
302
|
+
*
|
|
303
|
+
* A cached alias holds the target's instance under its own token, so
|
|
304
|
+
* evicting the target alone left the alias serving the old — by now
|
|
305
|
+
* disposed — instance after `replace()`/`remove()`.
|
|
306
|
+
*/
|
|
307
|
+
evictAliasesOf(target) {
|
|
308
|
+
for (const cached of [...this.singletonCache.keys()]) {
|
|
309
|
+
if (cached === target)
|
|
310
|
+
continue;
|
|
311
|
+
if (this.aliasChainReaches(cached, target))
|
|
312
|
+
this.evictSingleton(cached);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
aliasChainReaches(token, target) {
|
|
316
|
+
const visited = new Set();
|
|
317
|
+
let current = token;
|
|
318
|
+
for (;;) {
|
|
319
|
+
const registration = this.registry.get(current);
|
|
320
|
+
if (!registration)
|
|
321
|
+
return false;
|
|
322
|
+
const provider = normalizeProvider(registration.provider);
|
|
323
|
+
if (!isExistingProvider(provider))
|
|
324
|
+
return false;
|
|
325
|
+
const next = unwrapToken(provider.useExisting);
|
|
326
|
+
if (next === target)
|
|
327
|
+
return true;
|
|
328
|
+
if (visited.has(next))
|
|
329
|
+
return false;
|
|
330
|
+
visited.add(next);
|
|
331
|
+
current = next;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
261
334
|
}
|
|
262
335
|
function isPromiseLike(value) {
|
|
263
336
|
return (typeof value === "object" &&
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse dependency index for cached container instances.
|
|
3
|
+
*
|
|
4
|
+
* `replace()`/`remove()` used to evict only the changed token, so a
|
|
5
|
+
* singleton that had already captured it kept serving the old — by then
|
|
6
|
+
* disposed — instance. Recording which cached instance consumed which
|
|
7
|
+
* token lets eviction cascade to every consumer.
|
|
8
|
+
*/
|
|
9
|
+
import { ContainerScope as Scope } from "../containerScope/containerScope.type.js";
|
|
10
|
+
import type { Token } from "../containerToken/containerToken.type.js";
|
|
11
|
+
/** Looks up the lifetime of a registered token. */
|
|
12
|
+
export type LifetimeLookup = (token: Token<unknown>) => Scope | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Maps a token to the cached (SINGLETON or SCOPED) instances whose
|
|
15
|
+
* construction resolved it.
|
|
16
|
+
*/
|
|
17
|
+
export declare class DependentIndex {
|
|
18
|
+
#private;
|
|
19
|
+
/**
|
|
20
|
+
* Records that the nearest cached ancestor on `path` depends on
|
|
21
|
+
* `token`. Transient ancestors are skipped: they are rebuilt on every
|
|
22
|
+
* resolution, so the cached instance above them is the real consumer.
|
|
23
|
+
*/
|
|
24
|
+
record(token: Token<unknown>, path: readonly Token<unknown>[], lifetimeOf: LifetimeLookup): void;
|
|
25
|
+
/**
|
|
26
|
+
* Returns every token that transitively depends on `token`, nearest
|
|
27
|
+
* consumers last, and forgets them.
|
|
28
|
+
*/
|
|
29
|
+
take(token: Token<unknown>): readonly Token<unknown>[];
|
|
30
|
+
/** Forgets every recorded dependency. */
|
|
31
|
+
clear(): void;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=containerResolution.dependents.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse dependency index for cached container instances.
|
|
3
|
+
*
|
|
4
|
+
* `replace()`/`remove()` used to evict only the changed token, so a
|
|
5
|
+
* singleton that had already captured it kept serving the old — by then
|
|
6
|
+
* disposed — instance. Recording which cached instance consumed which
|
|
7
|
+
* token lets eviction cascade to every consumer.
|
|
8
|
+
*/
|
|
9
|
+
import { ContainerScope as Scope } from "../containerScope/containerScope.type.js";
|
|
10
|
+
/**
|
|
11
|
+
* Maps a token to the cached (SINGLETON or SCOPED) instances whose
|
|
12
|
+
* construction resolved it.
|
|
13
|
+
*/
|
|
14
|
+
export class DependentIndex {
|
|
15
|
+
#dependents = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Records that the nearest cached ancestor on `path` depends on
|
|
18
|
+
* `token`. Transient ancestors are skipped: they are rebuilt on every
|
|
19
|
+
* resolution, so the cached instance above them is the real consumer.
|
|
20
|
+
*/
|
|
21
|
+
record(token, path, lifetimeOf) {
|
|
22
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
23
|
+
const owner = path[i];
|
|
24
|
+
const lifetime = lifetimeOf(owner);
|
|
25
|
+
if (lifetime === Scope.SINGLETON || lifetime === Scope.SCOPED) {
|
|
26
|
+
if (owner === token)
|
|
27
|
+
return;
|
|
28
|
+
let set = this.#dependents.get(token);
|
|
29
|
+
if (set === undefined) {
|
|
30
|
+
set = new Set();
|
|
31
|
+
this.#dependents.set(token, set);
|
|
32
|
+
}
|
|
33
|
+
set.add(owner);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns every token that transitively depends on `token`, nearest
|
|
40
|
+
* consumers last, and forgets them.
|
|
41
|
+
*/
|
|
42
|
+
take(token) {
|
|
43
|
+
const ordered = [];
|
|
44
|
+
const visited = new Set([token]);
|
|
45
|
+
const visit = (current) => {
|
|
46
|
+
const direct = this.#dependents.get(current);
|
|
47
|
+
this.#dependents.delete(current);
|
|
48
|
+
if (direct === undefined)
|
|
49
|
+
return;
|
|
50
|
+
for (const dependent of direct) {
|
|
51
|
+
if (visited.has(dependent))
|
|
52
|
+
continue;
|
|
53
|
+
visited.add(dependent);
|
|
54
|
+
visit(dependent);
|
|
55
|
+
ordered.push(dependent);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
visit(token);
|
|
59
|
+
return ordered;
|
|
60
|
+
}
|
|
61
|
+
/** Forgets every recorded dependency. */
|
|
62
|
+
clear() {
|
|
63
|
+
this.#dependents.clear();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=containerResolution.dependents.js.map
|
|
@@ -15,6 +15,8 @@ export interface ResolutionCache {
|
|
|
15
15
|
has(token: Token<unknown>): boolean;
|
|
16
16
|
get(token: Token<unknown>): unknown;
|
|
17
17
|
set(token: Token<unknown>, value: unknown): void;
|
|
18
|
+
/** Drops one entry from this cache (not its parents). Optional. */
|
|
19
|
+
delete?(token: Token<unknown>): boolean;
|
|
18
20
|
clear(): void;
|
|
19
21
|
}
|
|
20
22
|
/**
|
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.1",
|
|
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.
|
|
21
|
+
"@zudojs/errors": "1.1.0"
|
|
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
|
},
|