@codefast/di 0.12.0 → 0.13.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/CHANGELOG.md +40 -0
- package/README.md +102 -19
- package/dist/container/container.d.ts +0 -1
- package/dist/container/container.js +72 -41
- package/dist/core/binding-builders.d.ts +5 -2
- package/dist/core/binding-declaration.d.ts +2 -2
- package/dist/core/binding-declaration.js +2 -2
- package/dist/decorators/inject.d.ts +3 -1
- package/dist/decorators/lifecycle.d.ts +9 -3
- package/dist/decorators/lifecycle.js +5 -1
- package/dist/errors.d.ts +22 -13
- package/dist/errors.js +35 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/introspection/explanation.js +26 -8
- package/dist/lifecycle/hooks.d.ts +2 -0
- package/dist/lifecycle/hooks.js +6 -0
- package/dist/lifecycle/scopes.d.ts +21 -2
- package/dist/lifecycle/scopes.js +86 -2
- package/dist/metadata/keys.d.ts +1 -1
- package/dist/metadata/keys.js +1 -1
- package/dist/resolution/path.d.ts +12 -2
- package/dist/resolution/path.js +22 -2
- package/dist/resolution/resolver.d.ts +12 -0
- package/dist/resolution/resolver.js +39 -8
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @codefast/di
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#1022](https://github.com/codefastlabs/codefast/pull/1022) **Breaking:** `Container` no longer declares `[Symbol.dispose]`, and `SyncDisposalNotSupportedError` is removed. A
|
|
8
|
+
deactivation hook may be async, so a container disposes through `await using` or `dispose()` only, and a synchronous
|
|
9
|
+
`using` on one is now a compile error instead of a `SyncDisposalNotSupportedError` at runtime. Replace `using` with
|
|
10
|
+
`await using`, and drop any `instanceof SyncDisposalNotSupportedError` check.
|
|
11
|
+
|
|
12
|
+
- [#1024](https://github.com/codefastlabs/codefast/pull/1024) **Breaking:** `@postConstruct()` and `@preDestroy()` now reject a private (`#name`) method, as they already rejected a
|
|
13
|
+
static or symbol-keyed one. The lifecycle manager calls a hook by name on the instance, so a private hook passed
|
|
14
|
+
`validate()` and then failed at resolve with an `InvalidMetadataError` that blamed the metadata reader, or at
|
|
15
|
+
`dispose()` for `@preDestroy()`. Decorating one now throws the new `PrivateLifecycleMethodError` when the class is
|
|
16
|
+
defined. The decorators' types also accept only a public, instance, string-named method, and `@inject` only an instance
|
|
17
|
+
accessor, so TypeScript reports all of these at the decoration site (TS1240, TS1241). To run private code from a hook,
|
|
18
|
+
call it from a public one.
|
|
19
|
+
|
|
20
|
+
- [#1022](https://github.com/codefastlabs/codefast/pull/1022) `TokenNotBoundError` and `NoMatchingBindingError` now carry `path`: the token names from the outermost request down to
|
|
21
|
+
the one that missed, alias hops included. A miss below the token asked for ends its message with that path —
|
|
22
|
+
`Path: UserService → UserRepository → Database → app:Logger` — so a deep graph says which consumer needed the binding; a
|
|
23
|
+
miss on the token asked for keeps its message as it was. Every lane reports the same path: sync and async, generated
|
|
24
|
+
plans, factory bodies, and `explain()` for an ancestor that selects nothing.
|
|
25
|
+
|
|
26
|
+
- [#1022](https://github.com/codefastlabs/codefast/pull/1022) **Breaking:** a `scoped` instance is now deactivated with the child container that cached it. Disposing that child runs
|
|
27
|
+
the binding's `onDeactivation`, the container-level `onDeactivation` hooks of the container that owns the binding, and
|
|
28
|
+
`@preDestroy()` for every scoped instance it cached, latest first and before its own singletons; unbinding a scoped
|
|
29
|
+
binding on the child that cached it deactivates that instance too. `.scoped()` now offers `onDeactivation`, and
|
|
30
|
+
`Module.fromBindings` accepts `onDeactivation` with `scope: "scoped"`, so per-request resources — a transaction, a
|
|
31
|
+
connection — can be released where they are declared. `validate()` no longer reports a deactivation hook on a
|
|
32
|
+
scoped-only token as unreachable. Code that relied on a scoped instance never being torn down, or that closed one by
|
|
33
|
+
hand after `dispose()`, should drop the manual close.
|
|
34
|
+
|
|
35
|
+
- [#1022](https://github.com/codefastlabs/codefast/pull/1022) **Breaking:** `validate()` now throws the error `resolve()` would for a singleton graph that cannot resolve — a required
|
|
36
|
+
dependency nothing binds (`TokenNotBoundError`), a slot nothing matches (`NoMatchingBindingError`), or a cycle
|
|
37
|
+
(`CircularDependencyError`) — with the path from that singleton down to the problem. A parent-owned singleton reached
|
|
38
|
+
from a child is now read from the parent's chain, as `resolve` reads it. It still leaves alone what it cannot decide: a
|
|
39
|
+
transient or scoped consumer, whose dependencies a child container may bind, and a request only a `when()` candidate
|
|
40
|
+
matches. A container that passed `validate()` while holding such a graph now fails it; bind the missing dependency, or
|
|
41
|
+
mark it `optional()` when it may be absent.
|
|
42
|
+
|
|
3
43
|
## 0.12.0
|
|
4
44
|
|
|
5
45
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @codefast/di
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Type-safe dependency injection for TypeScript applications, with zero dependencies, built on standard decorators with no
|
|
4
4
|
`reflect-metadata` and no `experimentalDecorators`.
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/@codefast/di)
|
|
@@ -20,12 +20,12 @@ its keep as the dependency graph grows.
|
|
|
20
20
|
|
|
21
21
|
- **Typed tokens.** `Token<Value>` flows through every `bind → resolve` path, so `resolve()` returns exactly the type
|
|
22
22
|
you registered.
|
|
23
|
-
- **
|
|
24
|
-
|
|
23
|
+
- **Standard decorators.** `@injectable`, `inject`, `optional`, `injectAll`, `@postConstruct`, and `@preDestroy` declare
|
|
24
|
+
dependencies explicitly — no runtime reflection.
|
|
25
25
|
- **Fluent bindings.** Constants, classes, sync and async factories, aliases, named/tagged/predicate constraints, and
|
|
26
26
|
lifecycle hooks compose in one invariant chain order.
|
|
27
27
|
- **Scopes with validation.** Choose `singleton`, `scoped`, or `transient`, and call `validate()` to catch captive
|
|
28
|
-
dependencies before the first request.
|
|
28
|
+
dependencies, missing bindings and cycles before the first request.
|
|
29
29
|
- **Modules and introspection.** Bundle bindings into reusable modules, and inspect a container or render its dependency
|
|
30
30
|
graph as DOT, Mermaid, Cytoscape, or React Flow.
|
|
31
31
|
|
|
@@ -36,7 +36,7 @@ pnpm add @codefast/di
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
`@codefast/di` runs on Node.js 24 or later and in Chrome and Edge 136, Firefox 136, or Safari 18.4 or later
|
|
39
|
-
([support policy](../../SUPPORT.md#browsers)). It requires TypeScript 7 or later, with
|
|
39
|
+
([support policy](../../SUPPORT.md#browsers)). It requires TypeScript 7 or later, with standard decorators. Leave
|
|
40
40
|
`experimentalDecorators` off — it's off by default. Its declarations use explicit resource management, which no numbered
|
|
41
41
|
`lib` declares yet, so your program needs those types: `@types/node` 24 or later loads them, and any other program adds
|
|
42
42
|
`ESNext.Disposable` to `lib` once its runtime ships explicit resource management. A browser program that targets Safari,
|
|
@@ -44,6 +44,67 @@ which has not shipped it, keeps `skipLibCheck` on and calls `dispose()` instead
|
|
|
44
44
|
published on 0.x and versioned on its own track: breaking changes ship as minor versions, so pin the minor version when
|
|
45
45
|
you need stability.
|
|
46
46
|
|
|
47
|
+
### Toolchain
|
|
48
|
+
|
|
49
|
+
No JavaScript engine runs decorators natively yet — the TC39 proposal went back to Stage 2.7 in May 2026 — so a
|
|
50
|
+
decorated class always passes through a transform that lowers it. What that takes depends on the toolchain:
|
|
51
|
+
|
|
52
|
+
| Toolchain | What it needs |
|
|
53
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| tsx | Nothing: it lowers standard decorators for the Node it runs on. |
|
|
55
|
+
| esbuild | A `target` below `esnext`, such as `es2024` or `node24`: at its default, `esnext`, decorators pass through. |
|
|
56
|
+
| Vite 8, and Vitest on it | The Babel decorators plugin: their Oxc transform lowers only `experimentalDecorators`. |
|
|
57
|
+
| SWC 1.15.21 or later | `jsc.parser.decorators: true` and `jsc.transform.decoratorVersion: "2023-11"`. |
|
|
58
|
+
| `tsc`, then Node directly | A `target` below `ESNext`, and `Symbol.metadata` installed before any decorated class is defined. |
|
|
59
|
+
|
|
60
|
+
For Vite and Vitest, add `@babel/core`, `@babel/plugin-proposal-decorators` and `@rolldown/plugin-babel` as dev
|
|
61
|
+
dependencies, then the decorators preset to the config — `vitest.config.ts` takes the same one:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// vite.config.ts
|
|
65
|
+
import babel, { defineRolldownBabelPreset } from "@rolldown/plugin-babel";
|
|
66
|
+
import { defineConfig } from "vite";
|
|
67
|
+
|
|
68
|
+
export default defineConfig({
|
|
69
|
+
plugins: [
|
|
70
|
+
babel({
|
|
71
|
+
presets: [
|
|
72
|
+
defineRolldownBabelPreset({
|
|
73
|
+
preset: () => ({ plugins: [["@babel/plugin-proposal-decorators", { version: "2023-11" }]] }),
|
|
74
|
+
// Every decorator holds an `@`, so Babel skips only files that cannot hold one.
|
|
75
|
+
rolldown: { filter: { code: "@" } },
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The filter is what keeps Babel off the files without decorators; the plugin applies it only when every preset carries
|
|
84
|
+
one and `plugins` is left empty. Keep it the plain `"@"`: a narrower pattern can miss a legal position such as
|
|
85
|
+
`export @injectable() class`, and a missed file ships its decorators raw. Beside other presets, list this one last,
|
|
86
|
+
since Babel runs presets last to first.
|
|
87
|
+
|
|
88
|
+
> **Warning.** Without the plugin the decorators pass through untouched, and nothing flags it at build time:
|
|
89
|
+
> `vite build` succeeds, then the bundle throws `SyntaxError: Invalid or unexpected token` as it loads — the same error
|
|
90
|
+
> Vitest reports for a test file that imports a decorated class.
|
|
91
|
+
|
|
92
|
+
`tsc` leaves decorators in its output at `target: "ESNext"`. At any lower target it lowers them, but on a runtime
|
|
93
|
+
without `Symbol.metadata` it hands them no metadata object, which every decorator here reports as
|
|
94
|
+
`MissingDecoratorMetadataError`. Install the symbol in a module the entry point imports first:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
(Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata");
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Not every toolchain lowers them yet. Node's own type stripping rejects decorators; Next.js cannot select the `2023-11`
|
|
101
|
+
version (vercel/next.js#96328 adds it); and Bun 1.4.2 and earlier inject the wrong values, silently, into a module that
|
|
102
|
+
holds two decorated classes (oven-sh/bun#28316).
|
|
103
|
+
|
|
104
|
+
[The specification → tsconfig setup](./spec/decorators.md#tsconfig-setup) covers the compiler options. And
|
|
105
|
+
[decorators](#decorators) are optional: an app wired through `toResolved`, `toDynamic` and `Module.fromBindings` needs
|
|
106
|
+
none of this.
|
|
107
|
+
|
|
47
108
|
## Quick start
|
|
48
109
|
|
|
49
110
|
```ts
|
|
@@ -98,9 +159,9 @@ A class constructor works as a key too: `container.bind(UserService).toSelf()`,
|
|
|
98
159
|
|
|
99
160
|
## Bindings
|
|
100
161
|
|
|
101
|
-
A binding describes how the container produces a value for a token. You declare it as a chain, in one fixed order:
|
|
102
|
-
strategy (the `to*` step, which produces the value
|
|
103
|
-
`container.bind(key).to*(…)` is required
|
|
162
|
+
A binding describes how the container produces a value for a token. You declare it as a chain, in one fixed order:
|
|
163
|
+
optional [constraints](#constraints), then a strategy (the `to*` step, which produces the value and registers the
|
|
164
|
+
binding), then an optional scope and lifecycle hooks. Only `container.bind(key).to*(…)` is required.
|
|
104
165
|
|
|
105
166
|
| Strategy | Produces |
|
|
106
167
|
| --------------------------------- | ----------------------------------------------------------------- |
|
|
@@ -134,7 +195,7 @@ no scope produces a new instance every time.
|
|
|
134
195
|
| Scope | Lifetime |
|
|
135
196
|
| -------------- | ---------------------------------------------------------------------------------------- |
|
|
136
197
|
| `.singleton()` | One instance for the container that owns the binding; children resolve the same instance |
|
|
137
|
-
| `.scoped()` | One instance per child container — resolving
|
|
198
|
+
| `.scoped()` | One instance per child container, deactivated with it — resolving outside a child throws |
|
|
138
199
|
| `.transient()` | A new instance on every resolution — the default |
|
|
139
200
|
|
|
140
201
|
```ts
|
|
@@ -148,9 +209,10 @@ container.bind(RequestContextToken).toSelf().scoped();
|
|
|
148
209
|
### Lifecycle hooks
|
|
149
210
|
|
|
150
211
|
A lifecycle hook runs your code as an instance is created or torn down. `.onActivation(fn)` runs right after an instance
|
|
151
|
-
is created, and may replace it; `.onDeactivation(fn)` runs
|
|
152
|
-
|
|
153
|
-
|
|
212
|
+
is created, and may replace it; `.onDeactivation(fn)` runs as the instance's lifetime ends — a singleton's when its
|
|
213
|
+
container is disposed or the binding is unbound, a scoped instance's when the child container that cached it is
|
|
214
|
+
disposed. A transient instance has no deactivation. Both hooks are also available container-wide, through
|
|
215
|
+
`container.onActivation(token, fn)` and `container.onDeactivation(token, fn)`:
|
|
154
216
|
|
|
155
217
|
```ts
|
|
156
218
|
container
|
|
@@ -212,6 +274,19 @@ container.resolve(StorageToken, { tag: Provider.of("s3") }); // → S3Storage
|
|
|
212
274
|
`injectAll` alike. Chain `.whenTagged(...)` once per criterion a slot carries, and request several at once with
|
|
213
275
|
`{ tags: [...] }`.
|
|
214
276
|
|
|
277
|
+
**One binding per slot; a collection with `many()`.** Binding a token again in a slot it already fills replaces the
|
|
278
|
+
earlier binding — `bind` is last-wins per slot, so `resolveAll` then finds only the newer one. To register several
|
|
279
|
+
implementations for `resolveAll` and `injectAll` to gather, mark each one a collection member with `.many()`:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
container.bind(PluginToken).many().to(AuditPlugin);
|
|
283
|
+
container.bind(PluginToken).many().to(MetricsPlugin);
|
|
284
|
+
|
|
285
|
+
container.resolveAll(PluginToken); // → an AuditPlugin and a MetricsPlugin, in registration order
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
A member keeps the default slot and is never what a single `resolve` selects.
|
|
289
|
+
|
|
215
290
|
**Predicate constraints — graph-aware selection.** When a slot isn't enough — when you need to choose based on _who_ is
|
|
216
291
|
resolving — pass a predicate to `.when(ctx => boolean)`. It runs at resolve time, after slot matching. These ready-made
|
|
217
292
|
predicates ship from the root entry:
|
|
@@ -252,7 +327,7 @@ class UserRepository {
|
|
|
252
327
|
constructor(
|
|
253
328
|
private readonly db: Database,
|
|
254
329
|
private readonly cache: Cache | undefined,
|
|
255
|
-
private readonly plugins:
|
|
330
|
+
private readonly plugins: ReadonlyArray<Plugin>,
|
|
256
331
|
private readonly audit: Logger,
|
|
257
332
|
) {}
|
|
258
333
|
|
|
@@ -349,17 +424,21 @@ parent's singletons, while each `scoped` binding gets a fresh instance per child
|
|
|
349
424
|
|
|
350
425
|
A captive dependency is a long-lived binding that holds a shorter-lived one — a `singleton` that depends on a `scoped`
|
|
351
426
|
or `transient` binding — which silently freezes that dependency for the singleton's whole life. `validate()` fails fast
|
|
352
|
-
on captive dependencies, and on constraints no request can satisfy, before the first resolve.
|
|
427
|
+
on captive dependencies, and on constraints no request can satisfy, before the first resolve. It also throws the error
|
|
428
|
+
`resolve()` would for a singleton graph that cannot resolve: a required dependency nothing binds, a slot nothing
|
|
429
|
+
matches, or a cycle.
|
|
353
430
|
|
|
354
431
|
```ts
|
|
355
|
-
container.validate(); // throws
|
|
432
|
+
container.validate(); // throws on the first violation — ScopeViolationError, TokenNotBoundError, …
|
|
356
433
|
```
|
|
357
434
|
|
|
435
|
+
A transient or scoped consumer's dependencies are left to `resolve()`, because a child container may still bind them.
|
|
436
|
+
|
|
358
437
|
### Disposal
|
|
359
438
|
|
|
360
439
|
`Container` implements `AsyncDisposable`, so `await using` runs every deactivation hook automatically as the block
|
|
361
|
-
exits
|
|
362
|
-
`
|
|
440
|
+
exits, and disposing a child deactivates the scoped instances it cached. Synchronous `using` is a compile error: a
|
|
441
|
+
deactivation hook may be async, so the container implements `Symbol.asyncDispose` only.
|
|
363
442
|
|
|
364
443
|
### Introspection
|
|
365
444
|
|
|
@@ -475,6 +554,9 @@ Every error extends `DiError` and carries a stable `code`, so you can branch on
|
|
|
475
554
|
| `MissingContainerContextError` | `MISSING_CONTAINER_CONTEXT` | An `@inject` accessor initializes with no container open |
|
|
476
555
|
| `DisposedContainerError` | `DISPOSED_CONTAINER` | A disposed container is used |
|
|
477
556
|
|
|
557
|
+
A miss below the token you asked for names its path: `TokenNotBoundError` and `NoMatchingBindingError` carry `path`, and
|
|
558
|
+
the message ends with it — `Path: UserService → UserRepository → app:Logger`.
|
|
559
|
+
|
|
478
560
|
The full taxonomy — including `MissingMetadataError`, `InvalidMetadataError`, `RebindUnboundTokenError`,
|
|
479
561
|
`AsyncModuleLoadError`, `InvalidBindingDeclarationError`, and the rest — is exported from the root entry and from
|
|
480
562
|
`@codefast/di/errors`.
|
|
@@ -495,8 +577,9 @@ introspection under `introspection/*`: `@codefast/di/introspection/inspector`, `
|
|
|
495
577
|
## Benchmarks
|
|
496
578
|
|
|
497
579
|
A first-party benchmark suite lives in the monorepo, at [`benchmarks/di`](../../benchmarks/di). It runs the same
|
|
498
|
-
workloads through `@codefast/di`, InversifyJS, Awilix, and
|
|
499
|
-
alongside the method that produced them. Run it yourself rather than taking any figure on
|
|
580
|
+
workloads through `@codefast/di`, InversifyJS, Awilix, tsyringe, Brandi, ditox, and injection-js, and its `RESULTS.md`
|
|
581
|
+
ledger records the numbers alongside the method that produced them. Run it yourself rather than taking any figure on
|
|
582
|
+
faith.
|
|
500
583
|
|
|
501
584
|
## Documentation
|
|
502
585
|
|
|
@@ -36,7 +36,6 @@ export interface Container {
|
|
|
36
36
|
createChild(): Container;
|
|
37
37
|
dispose(): Promise<void>;
|
|
38
38
|
[Symbol.asyncDispose](): Promise<void>;
|
|
39
|
-
[Symbol.dispose](): never;
|
|
40
39
|
initializeAsync(): Promise<void>;
|
|
41
40
|
validate(): void;
|
|
42
41
|
has<Names extends string = string>(token: Token<unknown, Names> | Constructor, options?: NoInfer<ResolveOptions<Names>>): boolean;
|
|
@@ -6,14 +6,14 @@ import { isSyncModule, MODULE_SETUP } from "#core/module";
|
|
|
6
6
|
import { BindingRegistry } from "#core/registry";
|
|
7
7
|
import { advanceDisposeEpoch, disposeEpochRef } from "#core/state-epoch";
|
|
8
8
|
import { tokenName } from "#core/token";
|
|
9
|
-
import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError,
|
|
9
|
+
import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError, UnreachableConstraintError, UnreachableLifecycleHookError, } from "#errors";
|
|
10
10
|
import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#injection/dependency-slot";
|
|
11
11
|
import { buildDependencyGraph } from "#introspection/dependency-graph";
|
|
12
12
|
import { RESOLUTION_DIAGNOSTICS } from "#introspection/diagnostics";
|
|
13
13
|
import { explainRequest } from "#introspection/explanation";
|
|
14
14
|
import { Inspector } from "#introspection/inspector";
|
|
15
15
|
import { LifecycleManager } from "#lifecycle/hooks";
|
|
16
|
-
import { ScopeManager } from "#lifecycle/scopes";
|
|
16
|
+
import { SCOPED_MISS, ScopeManager } from "#lifecycle/scopes";
|
|
17
17
|
import { MetadataReaderToken } from "#metadata/reader-token";
|
|
18
18
|
import { defaultMetadataReader } from "#metadata/symbol-reader";
|
|
19
19
|
import { verifyingMetadataReader } from "#metadata/verifying-reader";
|
|
@@ -223,7 +223,10 @@ class DefaultContainer {
|
|
|
223
223
|
else if (this.#owesConstantDeactivation(binding)) {
|
|
224
224
|
(pairs ??= []).push(binding, binding.value);
|
|
225
225
|
}
|
|
226
|
-
this.#scope.
|
|
226
|
+
const scopedInstance = this.#scope.takeScoped(binding.scopedCacheKey);
|
|
227
|
+
if (scopedInstance !== SCOPED_MISS) {
|
|
228
|
+
(pairs ??= []).push(binding, scopedInstance);
|
|
229
|
+
}
|
|
227
230
|
return pairs;
|
|
228
231
|
}
|
|
229
232
|
/**
|
|
@@ -637,6 +640,11 @@ class DefaultContainer {
|
|
|
637
640
|
await this.#scope.settleInflight();
|
|
638
641
|
const reader = this.#getMetadataReader();
|
|
639
642
|
const errors = [];
|
|
643
|
+
// A scoped instance may hold this container's singletons and never the reverse, so it goes first, latest first.
|
|
644
|
+
const scopedTeardown = this.#scope.deactivateScoped(errors);
|
|
645
|
+
if (scopedTeardown !== undefined) {
|
|
646
|
+
await scopedTeardown;
|
|
647
|
+
}
|
|
640
648
|
// Iterate a copy: a deactivation handler is user code, and the live list is what
|
|
641
649
|
// materializing or dropping a singleton mutates. Reverse materialization order, so a
|
|
642
650
|
// dependent tears down before the dependencies it may still reach through.
|
|
@@ -684,9 +692,6 @@ class DefaultContainer {
|
|
|
684
692
|
[Symbol.asyncDispose]() {
|
|
685
693
|
return this.dispose();
|
|
686
694
|
}
|
|
687
|
-
[Symbol.dispose]() {
|
|
688
|
-
throw new SyncDisposalNotSupportedError();
|
|
689
|
-
}
|
|
690
695
|
// ── Initialization ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
691
696
|
async initializeAsync() {
|
|
692
697
|
this.#assertNotDisposed();
|
|
@@ -729,8 +734,8 @@ class DefaultContainer {
|
|
|
729
734
|
if (!this.#isBoundInChain(hookToken)) {
|
|
730
735
|
throw new UnreachableLifecycleHookError(tokenName(hookToken), phase);
|
|
731
736
|
}
|
|
732
|
-
// A deactivation
|
|
733
|
-
//
|
|
737
|
+
// A deactivation never runs for a transient, so a hook on a token whose every binding is transient
|
|
738
|
+
// can never fire — the builder blocks it, `container.onDeactivation` cannot.
|
|
734
739
|
if (phase === "onDeactivation" && !this.#hasDeactivatableBindingInChain(hookToken)) {
|
|
735
740
|
throw new UnreachableLifecycleHookError(tokenName(hookToken), phase, "no-deactivatable-binding");
|
|
736
741
|
}
|
|
@@ -776,10 +781,10 @@ class DefaultContainer {
|
|
|
776
781
|
const parent = this.#parent;
|
|
777
782
|
return parent !== undefined && parent.#isBoundInChain(token);
|
|
778
783
|
}
|
|
779
|
-
// Whether the token has a binding a deactivation hook can run for —
|
|
784
|
+
// Whether the token has a binding a deactivation hook can run for — anything but a transient.
|
|
780
785
|
#hasDeactivatableBindingInChain(token) {
|
|
781
786
|
for (const binding of this.#registry.getAll(token)) {
|
|
782
|
-
if (effectiveBindingScope(binding)
|
|
787
|
+
if (effectiveBindingScope(binding) !== "transient") {
|
|
783
788
|
return true;
|
|
784
789
|
}
|
|
785
790
|
}
|
|
@@ -793,22 +798,21 @@ class DefaultContainer {
|
|
|
793
798
|
return binding.kind === "class" || binding.kind === "resolved" || binding.kind === "resolved-async";
|
|
794
799
|
}
|
|
795
800
|
/**
|
|
796
|
-
* DFS over explicit constructor / `toResolved*` dependency edges
|
|
797
|
-
* terminal binding for scope checks.
|
|
801
|
+
* DFS over explicit constructor / `toResolved*` dependency edges, following `toAlias` chains to the terminal binding.
|
|
798
802
|
*
|
|
799
|
-
* @remarks
|
|
800
|
-
*
|
|
803
|
+
* @remarks Every binding reached is a singleton resolved from its owner's chain, so a required miss or a repeat
|
|
804
|
+
* on one path fails however the graph is entered; a `toDynamic*` edge is scope-checked, its factory not descended.
|
|
801
805
|
*/
|
|
802
806
|
#validateSingletonBindingGraph(root, reader) {
|
|
803
807
|
const rootName = tokenName(root.token);
|
|
804
|
-
const dfs = (current, pathNames, pathBindingIds) => {
|
|
808
|
+
const dfs = (current, view, pathNames, pathBindingIds) => {
|
|
805
809
|
if (pathBindingIds.has(current.identifier)) {
|
|
806
|
-
|
|
810
|
+
throw new CircularDependencyError(pathNames);
|
|
807
811
|
}
|
|
808
812
|
const extendedPathIds = new Set(pathBindingIds);
|
|
809
813
|
extendedPathIds.add(current.identifier);
|
|
810
|
-
for (const edge of this.#collectStaticDependencyEdges(current, reader)) {
|
|
811
|
-
const { terminal, depTokenName } = edge;
|
|
814
|
+
for (const edge of this.#collectStaticDependencyEdges(current, view, reader, pathNames)) {
|
|
815
|
+
const { terminal, owner, depTokenName } = edge;
|
|
812
816
|
const depScope = this.#validationScopeFromTerminal(terminal);
|
|
813
817
|
if (depScope !== "singleton") {
|
|
814
818
|
throw new ScopeViolationError({
|
|
@@ -820,11 +824,11 @@ class DefaultContainer {
|
|
|
820
824
|
});
|
|
821
825
|
}
|
|
822
826
|
if (terminal.kind === "class" || terminal.kind === "resolved" || terminal.kind === "resolved-async") {
|
|
823
|
-
dfs(terminal, [...pathNames, depTokenName], extendedPathIds);
|
|
827
|
+
dfs(terminal, owner, [...pathNames, depTokenName], extendedPathIds);
|
|
824
828
|
}
|
|
825
829
|
}
|
|
826
830
|
};
|
|
827
|
-
dfs(root, [rootName], new Set());
|
|
831
|
+
dfs(root, this.#resolver, [rootName], new Set());
|
|
828
832
|
}
|
|
829
833
|
/**
|
|
830
834
|
* The scope this edge is judged against.
|
|
@@ -839,32 +843,35 @@ class DefaultContainer {
|
|
|
839
843
|
}
|
|
840
844
|
return terminal.scope;
|
|
841
845
|
}
|
|
842
|
-
|
|
846
|
+
/** Where an alias chain ends as `resolve` walks it from `view`: its terminal binding, or the names up to a miss. */
|
|
847
|
+
#followAliasChainToTerminal(entry, view, options) {
|
|
843
848
|
const cyclePath = [];
|
|
844
849
|
const seenAliasIds = new Set();
|
|
845
|
-
let current =
|
|
846
|
-
while (current
|
|
847
|
-
|
|
850
|
+
let current = entry;
|
|
851
|
+
while (current.binding.kind === "alias") {
|
|
852
|
+
const alias = current.binding;
|
|
853
|
+
if (seenAliasIds.has(alias.identifier)) {
|
|
848
854
|
throw new CircularDependencyError(cyclePath);
|
|
849
855
|
}
|
|
850
|
-
seenAliasIds.add(
|
|
851
|
-
cyclePath.push(tokenName(
|
|
852
|
-
const
|
|
853
|
-
const next = this.#resolver.peekBindingForValidate(nextToken, options);
|
|
856
|
+
seenAliasIds.add(alias.identifier);
|
|
857
|
+
cyclePath.push(tokenName(alias.token));
|
|
858
|
+
const next = view.peekBindingForValidate(alias.target, options);
|
|
854
859
|
if (next === undefined) {
|
|
855
|
-
return
|
|
860
|
+
return { found: false, missingToken: alias.target, hopNames: cyclePath };
|
|
856
861
|
}
|
|
857
|
-
current = next
|
|
862
|
+
current = next;
|
|
858
863
|
}
|
|
859
|
-
return current;
|
|
864
|
+
return { found: true, terminal: current.binding, owner: current.owner };
|
|
860
865
|
}
|
|
861
|
-
/** What one dependency could resolve to
|
|
862
|
-
#peekDependencyCandidates(dep, options) {
|
|
866
|
+
/** What one dependency could resolve to from `view`: every candidate for `injectAll`, else at most one. */
|
|
867
|
+
#peekDependencyCandidates(dep, view, options) {
|
|
863
868
|
if (dep.multi) {
|
|
864
|
-
return
|
|
869
|
+
return view
|
|
870
|
+
.peekCandidateBindingsForValidate(dep.token, options)
|
|
871
|
+
.map((binding) => ({ binding, owner: view.ownerForValidate(binding) }));
|
|
865
872
|
}
|
|
866
|
-
const found =
|
|
867
|
-
return found === undefined ? [] : [found
|
|
873
|
+
const found = view.peekBindingForValidate(dep.token, options);
|
|
874
|
+
return found === undefined ? [] : [found];
|
|
868
875
|
}
|
|
869
876
|
/** What a binding declares up front — a class's params, a factory's descriptors, else nothing. */
|
|
870
877
|
#staticDependencies(binding, reader) {
|
|
@@ -876,15 +883,39 @@ class DefaultContainer {
|
|
|
876
883
|
}
|
|
877
884
|
return [];
|
|
878
885
|
}
|
|
879
|
-
|
|
886
|
+
/**
|
|
887
|
+
* The bindings a singleton's declared dependencies resolve to, throwing the error `resolve` would for a required one
|
|
888
|
+
* nothing selects.
|
|
889
|
+
*/
|
|
890
|
+
#collectStaticDependencyEdges(binding, view, reader, pathNames) {
|
|
880
891
|
const edges = [];
|
|
881
892
|
for (const dep of this.#staticDependencies(binding, reader)) {
|
|
882
893
|
// An optional dependency that is absent peeks no candidate; one that is bound is captured like any other.
|
|
883
894
|
const depOptions = injectionSlotToResolveOptions(dep);
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
895
|
+
// A collection may be empty and an optional dependency absent, so only a required one can miss.
|
|
896
|
+
const required = !dep.multi && !dep.optional;
|
|
897
|
+
const candidates = this.#peekDependencyCandidates(dep, view, depOptions);
|
|
898
|
+
if (required && candidates.length === 0) {
|
|
899
|
+
const miss = view.missForValidate(dep.token, depOptions, [...pathNames, tokenName(dep.token)]);
|
|
900
|
+
if (miss !== undefined) {
|
|
901
|
+
throw miss;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
for (const candidate of candidates) {
|
|
905
|
+
const end = this.#followAliasChainToTerminal(candidate, view, depOptions);
|
|
906
|
+
if (end.found) {
|
|
907
|
+
edges.push({ terminal: end.terminal, owner: end.owner, depTokenName: tokenName(end.terminal.token) });
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (required) {
|
|
911
|
+
const miss = view.missForValidate(end.missingToken, depOptions, [
|
|
912
|
+
...pathNames,
|
|
913
|
+
...end.hopNames,
|
|
914
|
+
tokenName(end.missingToken),
|
|
915
|
+
]);
|
|
916
|
+
if (miss !== undefined) {
|
|
917
|
+
throw miss;
|
|
918
|
+
}
|
|
888
919
|
}
|
|
889
920
|
}
|
|
890
921
|
}
|
|
@@ -96,11 +96,14 @@ export interface TransientBindingBuilder<Value> {
|
|
|
96
96
|
id(): BindingIdentifier;
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
|
-
* The fluent chain after `scoped()`,
|
|
99
|
+
* The fluent chain after `scoped()`, where both lifecycle hooks stay available: a child deactivates what it cached.
|
|
100
100
|
*
|
|
101
101
|
* @since 0.3.16-canary.0
|
|
102
102
|
*/
|
|
103
|
-
export interface ScopedBindingBuilder<Value>
|
|
103
|
+
export interface ScopedBindingBuilder<Value> {
|
|
104
|
+
onActivation(fn: ActivationHandler<Value>): this;
|
|
105
|
+
onDeactivation(fn: DeactivationHandler<Value>): this;
|
|
106
|
+
id(): BindingIdentifier;
|
|
104
107
|
}
|
|
105
108
|
/**
|
|
106
109
|
* The fluent chain a constant enters once a lifecycle hook is added.
|
|
@@ -23,11 +23,11 @@ type SlotDefinition<Names extends string> = ({
|
|
|
23
23
|
} & Never<"whenNamed" | "whenTagged">);
|
|
24
24
|
/** How long an instance lives, and the hooks that lifetime allows. */
|
|
25
25
|
type LifetimeDefinition<Value> = {
|
|
26
|
-
readonly scope: "singleton";
|
|
26
|
+
readonly scope: "singleton" | "scoped";
|
|
27
27
|
readonly onActivation?: ActivationHandler<Value> | undefined;
|
|
28
28
|
readonly onDeactivation?: DeactivationHandler<Value> | undefined;
|
|
29
29
|
} | ({
|
|
30
|
-
readonly scope?: "transient" |
|
|
30
|
+
readonly scope?: "transient" | undefined;
|
|
31
31
|
readonly onActivation?: ActivationHandler<Value> | undefined;
|
|
32
32
|
} & Never<"onDeactivation">);
|
|
33
33
|
type BuiltDefinition<Value, Deps extends ReadonlyArray<InjectableDependency>> = ({
|
|
@@ -157,8 +157,8 @@ export function binding(key, definition) {
|
|
|
157
157
|
if (kind === "alias" && (onActivation !== undefined || onDeactivation !== undefined)) {
|
|
158
158
|
throw new InvalidBindingDeclarationError(name, "`toAlias` takes no lifecycle hook");
|
|
159
159
|
}
|
|
160
|
-
if (onDeactivation !== undefined && kind !== "constant" && scope
|
|
161
|
-
throw new InvalidBindingDeclarationError(name, '`onDeactivation` needs `scope: "singleton"`');
|
|
160
|
+
if (onDeactivation !== undefined && kind !== "constant" && scope === "transient") {
|
|
161
|
+
throw new InvalidBindingDeclarationError(name, '`onDeactivation` needs `scope: "singleton"` or `scope: "scoped"`');
|
|
162
162
|
}
|
|
163
163
|
if (definition.many !== undefined && definition.many !== true) {
|
|
164
164
|
throw new InvalidBindingDeclarationError(name, "`many` takes `true`");
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import type { Token } from "#core/token";
|
|
3
3
|
import type { Constructor } from "#core/types";
|
|
4
4
|
import type { InjectionDescriptor, InjectOptions } from "#injection/descriptor";
|
|
5
|
-
type ClassAccessorDecorator<This, Value> = (target: ClassAccessorDecoratorTarget<This, Value>, context: ClassAccessorDecoratorContext<This, Value>
|
|
5
|
+
type ClassAccessorDecorator<This, Value> = (target: ClassAccessorDecoratorTarget<This, Value>, context: ClassAccessorDecoratorContext<This, Value> & {
|
|
6
|
+
readonly static: false;
|
|
7
|
+
}) => ClassAccessorDecoratorResult<This, Value> | void;
|
|
6
8
|
/**
|
|
7
9
|
* Creates a dual-role value: an injection descriptor that also works as a class accessor decorator.
|
|
8
10
|
*
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
/** The only method a hook can be: the lifecycle reader calls it on the instance by its string name. */
|
|
2
|
+
type LifecycleMethodContext = ClassMethodDecoratorContext & {
|
|
3
|
+
readonly static: false;
|
|
4
|
+
readonly private: false;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
};
|
|
7
|
+
type LifecycleMethodDecorator = (target: unknown, context: LifecycleMethodContext) => void;
|
|
2
8
|
/**
|
|
3
9
|
* Marks an instance method to run after the container constructs and wires the instance.
|
|
4
10
|
*
|
|
5
11
|
* @since 0.3.16-canary.0
|
|
6
12
|
*/
|
|
7
|
-
export declare function postConstruct():
|
|
13
|
+
export declare function postConstruct(): LifecycleMethodDecorator;
|
|
8
14
|
/**
|
|
9
15
|
* Marks an instance method to run when the instance's container or scope is disposed.
|
|
10
16
|
*
|
|
11
17
|
* @since 0.3.16-canary.0
|
|
12
18
|
*/
|
|
13
|
-
export declare function preDestroy():
|
|
19
|
+
export declare function preDestroy(): LifecycleMethodDecorator;
|
|
14
20
|
export {};
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { decoratorMetadataOf } from "#decorators/metadata-record";
|
|
2
|
-
import { StaticMemberDecoratorError, SymbolKeyedLifecycleError } from "#errors";
|
|
2
|
+
import { PrivateLifecycleMethodError, StaticMemberDecoratorError, SymbolKeyedLifecycleError } from "#errors";
|
|
3
3
|
import { LIFECYCLE_KEY } from "#metadata/keys";
|
|
4
4
|
/** Records the decorated method under one lifecycle phase; both decorators differ only in that phase. */
|
|
5
5
|
function recordLifecycleMethod(phase) {
|
|
6
|
+
// Typed wide on purpose: the checks below are what an untyped caller meets instead of the compiler.
|
|
6
7
|
return function (target, context) {
|
|
7
8
|
if (context.static) {
|
|
8
9
|
throw new StaticMemberDecoratorError(phase, String(context.name));
|
|
9
10
|
}
|
|
11
|
+
if (context.private) {
|
|
12
|
+
throw new PrivateLifecycleMethodError(phase, String(context.name));
|
|
13
|
+
}
|
|
10
14
|
// The lifecycle reader keys methods by their string name, so a symbol-keyed method can never be
|
|
11
15
|
// found again — fail here, where the declaration is, rather than at resolve.
|
|
12
16
|
if (typeof context.name === "symbol") {
|