injectus 0.1.1-alpha.0 → 0.2.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 +320 -1
- package/dist/binding.d.ts +18 -0
- package/dist/binding.js +7 -0
- package/dist/context.d.ts +50 -0
- package/dist/context.js +39 -0
- package/dist/disposable.d.ts +10 -0
- package/dist/disposable.js +14 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +62 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -1
- package/dist/injector.d.ts +69 -0
- package/dist/injector.js +198 -0
- package/dist/lifetime.d.ts +15 -0
- package/dist/lifetime.js +23 -0
- package/dist/provider.d.ts +29 -0
- package/dist/provider.js +1 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +24 -0
- package/package.json +22 -5
package/README.md
CHANGED
|
@@ -1,3 +1,322 @@
|
|
|
1
1
|
# Injectus
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/injectus)
|
|
4
|
+
[](https://github.com/hossam7amdy/injectus/actions/workflows/ci.yaml)
|
|
5
|
+
[](https://coveralls.io/github/hossam7amdy/injectus)
|
|
6
|
+
[](https://github.com/hossam7amdy/injectus/blob/main/LICENSE)
|
|
7
|
+
[](https://www.npmjs.com/package/injectus)
|
|
8
|
+
|
|
9
|
+
Zero-dependency, decorator-free IoC container for Node.js.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Injector, inject, Lifetime, InjectionToken } from "injectus";
|
|
13
|
+
|
|
14
|
+
const DB_URL = new InjectionToken<string>("DB_URL");
|
|
15
|
+
|
|
16
|
+
class Database {
|
|
17
|
+
url = inject(DB_URL);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class UserService {
|
|
21
|
+
db = inject(Database);
|
|
22
|
+
findAll() {
|
|
23
|
+
return this.db.url;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const injector = Injector.create({
|
|
28
|
+
providers: [
|
|
29
|
+
{ provide: DB_URL, useValue: "postgres://localhost/app" },
|
|
30
|
+
Database,
|
|
31
|
+
UserService,
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
injector.resolve(UserService).findAll();
|
|
36
|
+
|
|
37
|
+
await injector.dispose();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install injectus
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
pnpm add injectus
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
yarn add injectus
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Requires Node.js ≥ 22.6.0.** The library uses `Symbol.asyncDispose` (TC39 Explicit Resource Management) and ships as native ESM. No browser support — designed for server-side use where synchronous resolution is a guarantee.
|
|
55
|
+
|
|
56
|
+
> **`await using` requires Node.js ≥ 24.** The `await using` syntax is a parse-time feature unavailable on Node 22.x. On Node 22 use the equivalent manual form: `const injector = Injector.create(...)` followed by `await injector.dispose()`.
|
|
57
|
+
|
|
58
|
+
> **Design note:** The functional `inject()` API is directly inspired by Angular's modern DI (v14+). Everything else is stripped for the backend: no zone.js, no component trees, no async factories — just plain injectors, explicit lifetimes, and safe disposal.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Providers
|
|
63
|
+
|
|
64
|
+
Five registration forms, all accepted in the `providers` array:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Bare class — sugar for { provide: C, useClass: C, lifetime: Singleton }
|
|
68
|
+
providers: [MyService]
|
|
69
|
+
|
|
70
|
+
// Construct a class (inject() works in field initializers)
|
|
71
|
+
{ provide: Cache, useClass: RedisCache, lifetime: Lifetime.Singleton }
|
|
72
|
+
|
|
73
|
+
// Factory function (inject() works inside the body)
|
|
74
|
+
{ provide: Config, useFactory: () => loadConfig() }
|
|
75
|
+
|
|
76
|
+
// Pre-built value — the injector never disposes it
|
|
77
|
+
{ provide: DB_URL, useValue: "postgres://localhost/app" }
|
|
78
|
+
|
|
79
|
+
// Alias — re-dispatches through the calling injector, so child overrides apply
|
|
80
|
+
{ provide: ICache, useExisting: RedisCache }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Last registration for a token wins — child injectors shadow parent bindings.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Tokens
|
|
88
|
+
|
|
89
|
+
Classes are their own tokens. For interfaces, primitives, and config use `InjectionToken`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { InjectionToken } from "injectus";
|
|
93
|
+
|
|
94
|
+
const PORT = new InjectionToken<number>("PORT");
|
|
95
|
+
const DB_URL = new InjectionToken<string>("DB_URL");
|
|
96
|
+
abstract class Cache {
|
|
97
|
+
abstract get(k: string): string | null;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Two tokens with the same description are **distinct keys** — identity, not name, is the key.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Lifetimes
|
|
106
|
+
|
|
107
|
+
| Lifetime | Behaviour |
|
|
108
|
+
| ----------------------- | ------------------------------------------------------------------------------- |
|
|
109
|
+
| `Singleton` _(default)_ | One instance per owning injector. Cached. |
|
|
110
|
+
| `Scoped` | One instance per child injector. |
|
|
111
|
+
| `Transient` | Fresh instance on every `resolve()` / `inject()`. Never cached, never disposed. |
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { Lifetime } from "injectus";
|
|
115
|
+
|
|
116
|
+
{ provide: Logger, useClass: Logger, lifetime: Lifetime.Singleton }
|
|
117
|
+
{ provide: Session, useClass: Session, lifetime: Lifetime.Scoped }
|
|
118
|
+
{ provide: RequestId, useFactory: () => crypto.randomUUID(),
|
|
119
|
+
lifetime: Lifetime.Transient }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Captive dependency detection.** Resolving a `Singleton` that depends — directly or transitively — on a `Scoped` throws `CaptiveDependencyError` at resolve time, not silently in production. This is a deliberate safety guarantee that most DI libraries skip.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Hierarchy
|
|
127
|
+
|
|
128
|
+
Child injectors inherit all parent bindings and own their `Scoped` instances independently. This maps naturally to HTTP request lifecycles.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const app = Injector.create({
|
|
132
|
+
name: "app",
|
|
133
|
+
providers: [Database, UserService],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// per-request child
|
|
137
|
+
const req = Injector.create({
|
|
138
|
+
name: "request",
|
|
139
|
+
parent: app,
|
|
140
|
+
providers: [
|
|
141
|
+
{
|
|
142
|
+
provide: REQUEST_ID,
|
|
143
|
+
useFactory: () => crypto.randomUUID(),
|
|
144
|
+
lifetime: Lifetime.Scoped,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
provide: RequestLogger,
|
|
148
|
+
useClass: RequestLogger,
|
|
149
|
+
lifetime: Lifetime.Scoped,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
req.resolve(UserService); // Database comes from app
|
|
155
|
+
req.resolve(RequestLogger); // owned and cached by req
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each injector disposes only what it constructed — disposing `req` never touches `app`'s instances.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## inject()
|
|
163
|
+
|
|
164
|
+
Resolves a token through the **active injection context** — the injector currently constructing an instance. Valid only synchronously inside a `useFactory` body or a class field initializer.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
class Mailer {
|
|
168
|
+
smtp = inject(SmtpClient);
|
|
169
|
+
audit = inject(AuditService, { optional: true }); // null if not registered
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Calling `inject()` outside a construction context throws `InjectionContextError`. It does not work across async boundaries (`await`, `setTimeout`, `Promise`) — all wiring must be synchronous.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## withInjector()
|
|
178
|
+
|
|
179
|
+
Runs a function under a given injector's context. `inject()` calls inside `fn` resolve through `injector`. Useful for manual wiring and test setup.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { withInjector } from "injectus";
|
|
183
|
+
|
|
184
|
+
const svc = withInjector(injector, () => new MyService());
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Disposal
|
|
190
|
+
|
|
191
|
+
Injectors implement TC39 Explicit Resource Management. Instances with `Symbol.dispose` or `Symbol.asyncDispose` are tracked and called in **reverse construction order (LIFO)** on `dispose()`.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
class DbPool {
|
|
195
|
+
async [Symbol.asyncDispose]() { await this.pool.end(); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Manual
|
|
199
|
+
await injector.dispose();
|
|
200
|
+
|
|
201
|
+
// Automatic with `await using`
|
|
202
|
+
{
|
|
203
|
+
await using injector = Injector.create({ providers: [...] });
|
|
204
|
+
// injector.dispose() is called at block exit, even on throw
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Rules:**
|
|
209
|
+
|
|
210
|
+
- Each injector disposes only what it constructed. Parents and children are independent.
|
|
211
|
+
- `useValue` providers are **never disposed** — the caller owns them.
|
|
212
|
+
- `Transient` instances are **never tracked** — manage their lifecycle at the call site.
|
|
213
|
+
- `dispose()` is idempotent. Concurrent calls share one run.
|
|
214
|
+
- Multiple failing disposers are collected into a single `AggregateError`.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
**Unit — swap real deps with values:**
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { Injector, inject, withInjector } from "injectus";
|
|
224
|
+
|
|
225
|
+
class UserService {
|
|
226
|
+
db = inject(Database);
|
|
227
|
+
logger = inject(Logger);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const injector = Injector.create({
|
|
231
|
+
providers: [
|
|
232
|
+
{ provide: Database, useValue: mockDb },
|
|
233
|
+
{ provide: Logger, useValue: mockLogger },
|
|
234
|
+
UserService,
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const svc = injector.resolve(UserService);
|
|
239
|
+
// svc.db === mockDb, svc.logger === mockLogger
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Integration — shadow a single binding in production config:**
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
const injector = Injector.create({
|
|
246
|
+
providers: [
|
|
247
|
+
...productionProviders,
|
|
248
|
+
{ provide: Database, useValue: testDb }, // shadows the real Database
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Error reference
|
|
256
|
+
|
|
257
|
+
| Error | When |
|
|
258
|
+
| ------------------------- | -------------------------------------------------------------- |
|
|
259
|
+
| `TokenNotFoundError` | No provider registered for the token |
|
|
260
|
+
| `CircularDependencyError` | Cycle in the dependency graph (`A → B → A`) |
|
|
261
|
+
| `CaptiveDependencyError` | Singleton holds a Scoped dependency (directly or transitively) |
|
|
262
|
+
| `InjectionContextError` | `inject()` called outside a factory or field initializer |
|
|
263
|
+
| `InjectorDisposedError` | Resolved from a disposed injector, or ancestor was disposed |
|
|
264
|
+
|
|
265
|
+
`CircularDependencyError` and `CaptiveDependencyError` extend `DependencyPathError`, which exposes the full dependency path root-to-leaf as `path: readonly Token[]` (also rendered in `message`).
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## API reference
|
|
270
|
+
|
|
271
|
+
### `Injector.create(options)`
|
|
272
|
+
|
|
273
|
+
| Option | Type | Default | Description |
|
|
274
|
+
| ----------------- | ------------ | ----------------------------- | -------------------------------------- |
|
|
275
|
+
| `providers` | `Provider[]` | required | Bindings for this injector |
|
|
276
|
+
| `parent` | `Injector` | — | Parent injector; omit for root |
|
|
277
|
+
| `name` | `string` | `"root"` / `"<parent>.child"` | Debug label, appears in error messages |
|
|
278
|
+
| `defaultLifetime` | `Lifetime` | `Lifetime.Singleton` | Lifetime when a provider omits one |
|
|
279
|
+
|
|
280
|
+
### `injector.resolve(token, options?)`
|
|
281
|
+
|
|
282
|
+
Resolves synchronously. Pass `{ optional: true }` to get `null` instead of throwing on a missing token.
|
|
283
|
+
|
|
284
|
+
### `inject(token, options?)`
|
|
285
|
+
|
|
286
|
+
Functional injection. Must be called synchronously during provider construction. Mirrors `injector.resolve()` through the active context.
|
|
287
|
+
|
|
288
|
+
### `withInjector(injector, fn)`
|
|
289
|
+
|
|
290
|
+
Runs `fn` with `injector` as the active context. Returns `fn`'s return value.
|
|
291
|
+
|
|
292
|
+
### `Injector` properties
|
|
293
|
+
|
|
294
|
+
| Property | Type | Description |
|
|
295
|
+
| ----------------------- | --------------------- | ----------------------------------- |
|
|
296
|
+
| `parent` | `Injector \| null` | Parent injector, or `null` for root |
|
|
297
|
+
| `name` | `string` | Debug label |
|
|
298
|
+
| `disposed` | `boolean` | `true` after `dispose()` is called |
|
|
299
|
+
| `dispose()` | `() => Promise<void>` | Dispose tracked instances LIFO |
|
|
300
|
+
| `[Symbol.asyncDispose]` | `() => Promise<void>` | Alias — enables `await using` |
|
|
301
|
+
|
|
302
|
+
### `InjectionToken<T>`
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
const TOKEN = new InjectionToken<T>("description");
|
|
306
|
+
token.description; // string
|
|
307
|
+
token.toString(); // "InjectionToken(description)"
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### `Lifetime`
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
Lifetime.Singleton; // "singleton"
|
|
314
|
+
Lifetime.Scoped; // "scoped"
|
|
315
|
+
Lifetime.Transient; // "transient"
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Author
|
|
321
|
+
|
|
322
|
+
[Hossam Hamdy](https://github.com/hossam7amdy) · [Issues](https://github.com/hossam7amdy/injectus/issues)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { InjectOptions } from "./context.ts";
|
|
2
|
+
import type { Lifetime } from "./lifetime.ts";
|
|
3
|
+
/** No cached value yet. */
|
|
4
|
+
export declare const EMPTY: unique symbol;
|
|
5
|
+
export type Empty = typeof EMPTY;
|
|
6
|
+
/** Hydration in progress — cycle marker. */
|
|
7
|
+
export declare const CIRCULAR: unique symbol;
|
|
8
|
+
export type Circular = typeof CIRCULAR;
|
|
9
|
+
/**
|
|
10
|
+
* Fixed field order across all bindings keeps V8's hidden class
|
|
11
|
+
* monomorphic on the hot resolve path — always go through `makeBinding`.
|
|
12
|
+
*/
|
|
13
|
+
export interface Binding<T = unknown> {
|
|
14
|
+
factory: ((options?: InjectOptions) => T | null) | undefined;
|
|
15
|
+
value: T | Empty | Circular;
|
|
16
|
+
lifetime: Lifetime;
|
|
17
|
+
}
|
|
18
|
+
export declare function makeBinding<T>(factory: ((options?: InjectOptions) => T | null) | undefined, value: T | Empty | Circular, lifetime: Lifetime): Binding<T>;
|
package/dist/binding.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Lifetime } from "./lifetime.ts";
|
|
2
|
+
import type { Token } from "./token.ts";
|
|
3
|
+
/** Options for `injector.resolve()`. */
|
|
4
|
+
export interface InjectOptions {
|
|
5
|
+
/** Return `null` instead of throwing when the token has no provider. */
|
|
6
|
+
optional?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** @internal Resolve-facing view of `Injector` consumed by the injection context. */
|
|
9
|
+
export interface Injector {
|
|
10
|
+
resolve<T>(token: Token<T>): T;
|
|
11
|
+
resolve<T>(token: Token<T>, options: {
|
|
12
|
+
optional: true;
|
|
13
|
+
}): T | null;
|
|
14
|
+
resolve<T>(token: Token<T>, options?: InjectOptions): T | null;
|
|
15
|
+
}
|
|
16
|
+
export interface InjectionContext {
|
|
17
|
+
injector: Injector;
|
|
18
|
+
/** Strictest lifetime seen so far. Lets `hydrate()` detect captive dependencies in O(1). */
|
|
19
|
+
effectiveLifetime: Lifetime | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare function getInjectionContext(): InjectionContext | undefined;
|
|
22
|
+
/** @internal Set current context, return previous. Always restore in a `finally`. */
|
|
23
|
+
export declare function setInjectionContext(ctx: InjectionContext | undefined): InjectionContext | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a token through the active injection context.
|
|
26
|
+
*
|
|
27
|
+
* Valid only synchronously inside a factory or class field initializer
|
|
28
|
+
* invoked by an `Injector`. Throws `InjectionContextError` elsewhere.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* class UserService {
|
|
32
|
+
* db = inject(Database);
|
|
33
|
+
* config = inject(CONFIG, { optional: true }); // null when missing
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
export declare function inject<T>(token: Token<T>): T;
|
|
37
|
+
export declare function inject<T>(token: Token<T>, options: {
|
|
38
|
+
optional: true;
|
|
39
|
+
}): T | null;
|
|
40
|
+
export declare function inject<T>(token: Token<T>, options?: InjectOptions): T | null;
|
|
41
|
+
/**
|
|
42
|
+
* Run `fn` under `injector`'s injection context.
|
|
43
|
+
*
|
|
44
|
+
* `inject()` calls inside `fn` resolve through `injector`.
|
|
45
|
+
* Useful for manual wiring and test setup.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* const svc = withInjector(injector, () => new MyService());
|
|
49
|
+
*/
|
|
50
|
+
export declare function withInjector<R>(injector: Injector, fn: () => R): R;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { InjectionContextError } from "./errors.js";
|
|
2
|
+
let currentContext;
|
|
3
|
+
export function getInjectionContext() {
|
|
4
|
+
return currentContext;
|
|
5
|
+
}
|
|
6
|
+
/** @internal Set current context, return previous. Always restore in a `finally`. */
|
|
7
|
+
export function setInjectionContext(ctx) {
|
|
8
|
+
const prev = currentContext;
|
|
9
|
+
currentContext = ctx;
|
|
10
|
+
return prev;
|
|
11
|
+
}
|
|
12
|
+
export function inject(token, options) {
|
|
13
|
+
const ctx = currentContext;
|
|
14
|
+
if (ctx === undefined) {
|
|
15
|
+
throw new InjectionContextError(token);
|
|
16
|
+
}
|
|
17
|
+
return ctx.injector.resolve(token, options);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run `fn` under `injector`'s injection context.
|
|
21
|
+
*
|
|
22
|
+
* `inject()` calls inside `fn` resolve through `injector`.
|
|
23
|
+
* Useful for manual wiring and test setup.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const svc = withInjector(injector, () => new MyService());
|
|
27
|
+
*/
|
|
28
|
+
export function withInjector(injector, fn) {
|
|
29
|
+
const prev = setInjectionContext({
|
|
30
|
+
injector,
|
|
31
|
+
effectiveLifetime: currentContext?.effectiveLifetime,
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
return fn();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
setInjectionContext(prev);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Any object implementing TC39 Explicit Resource Management (`Symbol.dispose` or `Symbol.asyncDispose`). */
|
|
2
|
+
export type DisposableLike = {
|
|
3
|
+
[Symbol.dispose](): void;
|
|
4
|
+
} | {
|
|
5
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
/** @internal */
|
|
8
|
+
export declare function isDisposable(value: unknown): value is DisposableLike;
|
|
9
|
+
/** @internal Invoke the disposer. May return a Promise. */
|
|
10
|
+
export declare function disposerOf(value: any): void | Promise<void>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export function isDisposable(value) {
|
|
3
|
+
if (value == null)
|
|
4
|
+
return false;
|
|
5
|
+
return (typeof value[Symbol.asyncDispose] === "function" ||
|
|
6
|
+
typeof value[Symbol.dispose] === "function");
|
|
7
|
+
}
|
|
8
|
+
/** @internal Invoke the disposer. May return a Promise. */
|
|
9
|
+
export function disposerOf(value) {
|
|
10
|
+
if (typeof value[Symbol.asyncDispose] === "function") {
|
|
11
|
+
return value[Symbol.asyncDispose]();
|
|
12
|
+
}
|
|
13
|
+
return value[Symbol.dispose]();
|
|
14
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Token } from "./token.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Base for errors that accumulate a dependency path as the exception
|
|
4
|
+
* unwinds through nested `hydrate()` frames.
|
|
5
|
+
*/
|
|
6
|
+
export declare abstract class DependencyPathError extends Error {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(leaf: Token);
|
|
9
|
+
/** Full dependency path, root-to-leaf. */
|
|
10
|
+
get path(): readonly Token[];
|
|
11
|
+
/** Prepend `token` to `error`'s path as the exception unwinds one frame. */
|
|
12
|
+
static prepend(error: DependencyPathError, token: Token): void;
|
|
13
|
+
}
|
|
14
|
+
/** Thrown when a cycle is detected in the dependency graph. The message renders the full path root-to-leaf. */
|
|
15
|
+
export declare class CircularDependencyError extends DependencyPathError {
|
|
16
|
+
readonly name = "CircularDependencyError";
|
|
17
|
+
get message(): string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when a singleton holds — directly or transitively — a scoped dependency.
|
|
21
|
+
* The message labels the scoped dependency and renders the full path root-to-leaf.
|
|
22
|
+
*/
|
|
23
|
+
export declare class CaptiveDependencyError extends DependencyPathError {
|
|
24
|
+
readonly name = "CaptiveDependencyError";
|
|
25
|
+
get message(): string;
|
|
26
|
+
}
|
|
27
|
+
/** Thrown when no provider is registered for a token. */
|
|
28
|
+
export declare class TokenNotFoundError extends Error {
|
|
29
|
+
readonly name = "TokenNotFoundError";
|
|
30
|
+
constructor(token: Token, name: string);
|
|
31
|
+
}
|
|
32
|
+
/** Thrown when `inject()` is called outside an active injection context. */
|
|
33
|
+
export declare class InjectionContextError extends Error {
|
|
34
|
+
readonly name = "InjectionContextError";
|
|
35
|
+
constructor(token: Token);
|
|
36
|
+
}
|
|
37
|
+
/** Thrown when an operation is attempted on a disposed injector. */
|
|
38
|
+
export declare class InjectorDisposedError extends Error {
|
|
39
|
+
readonly name = "InjectorDisposedError";
|
|
40
|
+
constructor(name: string);
|
|
41
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { tokenName } from "./token.js";
|
|
2
|
+
/**
|
|
3
|
+
* Base for errors that accumulate a dependency path as the exception
|
|
4
|
+
* unwinds through nested `hydrate()` frames.
|
|
5
|
+
*/
|
|
6
|
+
export class DependencyPathError extends Error {
|
|
7
|
+
#path;
|
|
8
|
+
constructor(leaf) {
|
|
9
|
+
super();
|
|
10
|
+
this.#path = [leaf];
|
|
11
|
+
}
|
|
12
|
+
/** Full dependency path, root-to-leaf. */
|
|
13
|
+
get path() {
|
|
14
|
+
return this.#path;
|
|
15
|
+
}
|
|
16
|
+
/** Prepend `token` to `error`'s path as the exception unwinds one frame. */
|
|
17
|
+
static prepend(error, token) {
|
|
18
|
+
error.#path.unshift(token);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Thrown when a cycle is detected in the dependency graph. The message renders the full path root-to-leaf. */
|
|
22
|
+
export class CircularDependencyError extends DependencyPathError {
|
|
23
|
+
name = "CircularDependencyError";
|
|
24
|
+
get message() {
|
|
25
|
+
return `Circular dependency: ${this.path.map(tokenName).join(" -> ")}.`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Thrown when a singleton holds — directly or transitively — a scoped dependency.
|
|
30
|
+
* The message labels the scoped dependency and renders the full path root-to-leaf.
|
|
31
|
+
*/
|
|
32
|
+
export class CaptiveDependencyError extends DependencyPathError {
|
|
33
|
+
name = "CaptiveDependencyError";
|
|
34
|
+
get message() {
|
|
35
|
+
const path = this.path;
|
|
36
|
+
const scoped = tokenName(path[path.length - 1]);
|
|
37
|
+
return (`Captive dependency: ${scoped} (scoped) cannot live inside a singleton. ` +
|
|
38
|
+
`Chain: ${path.map(tokenName).join(" -> ")}.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Thrown when no provider is registered for a token. */
|
|
42
|
+
export class TokenNotFoundError extends Error {
|
|
43
|
+
name = "TokenNotFoundError";
|
|
44
|
+
constructor(token, name) {
|
|
45
|
+
super(`No provider registered for ${tokenName(token)} (Injector: "${name}").`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Thrown when `inject()` is called outside an active injection context. */
|
|
49
|
+
export class InjectionContextError extends Error {
|
|
50
|
+
name = "InjectionContextError";
|
|
51
|
+
constructor(token) {
|
|
52
|
+
super(`inject(${tokenName(token)}) called outside an injection context. ` +
|
|
53
|
+
`inject() may only run synchronously inside a factory or class field initializer invoked by an Injector.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Thrown when an operation is attempted on a disposed injector. */
|
|
57
|
+
export class InjectorDisposedError extends Error {
|
|
58
|
+
name = "InjectorDisposedError";
|
|
59
|
+
constructor(name) {
|
|
60
|
+
super(`Injector "${name}" has been disposed.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { type InjectOptions, inject, withInjector, } from "./context.ts";
|
|
2
|
+
export { CaptiveDependencyError, CircularDependencyError, DependencyPathError, InjectionContextError, InjectorDisposedError, TokenNotFoundError, } from "./errors.ts";
|
|
3
|
+
export { Injector, type InjectorOptions, } from "./injector.ts";
|
|
4
|
+
export { Lifetime } from "./lifetime.ts";
|
|
5
|
+
export type { ClassProvider, ExistingProvider, FactoryProvider, Provider, ValueProvider, } from "./provider.ts";
|
|
6
|
+
export { type Constructor, InjectionToken, type Token } from "./token.ts";
|
package/dist/index.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
// Injection context
|
|
2
|
+
export { inject, withInjector, } from "./context.js";
|
|
3
|
+
// Errors
|
|
4
|
+
export { CaptiveDependencyError, CircularDependencyError, DependencyPathError, InjectionContextError, InjectorDisposedError, TokenNotFoundError, } from "./errors.js";
|
|
5
|
+
// Injector
|
|
6
|
+
export { Injector, } from "./injector.js";
|
|
7
|
+
// Lifetimes
|
|
8
|
+
export { Lifetime } from "./lifetime.js";
|
|
9
|
+
// Tokens
|
|
10
|
+
export { InjectionToken } from "./token.js";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Injector as ContextInjector, type InjectOptions } from "./context.ts";
|
|
2
|
+
import { Lifetime } from "./lifetime.ts";
|
|
3
|
+
import type { Provider } from "./provider.ts";
|
|
4
|
+
import type { Token } from "./token.ts";
|
|
5
|
+
/** Options passed to `Injector.create()`. */
|
|
6
|
+
export interface InjectorOptions {
|
|
7
|
+
/** Provider registrations for this injector. */
|
|
8
|
+
providers: Provider[];
|
|
9
|
+
/** Debug label. Defaults to `"root"` for root injectors and `"<parent>.child"` for children. */
|
|
10
|
+
name?: string;
|
|
11
|
+
/** Parent injector. Omit to create a root; supply to create a child. */
|
|
12
|
+
parent?: Injector;
|
|
13
|
+
/** Lifetime applied when a provider omits `lifetime`. @default Lifetime.Singleton */
|
|
14
|
+
defaultLifetime?: Lifetime;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* IoC container. Holds provider bindings, caches instances by lifetime,
|
|
18
|
+
* and disposes tracked instances in reverse construction order on `dispose()`.
|
|
19
|
+
*
|
|
20
|
+
* Root injectors own singletons. Child injectors (via `parent`) own scoped
|
|
21
|
+
* instances and inherit every parent binding. Each injector disposes only what
|
|
22
|
+
* it constructed — parents and children are independent.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const root = Injector.create({ providers: [Database] });
|
|
26
|
+
* const child = Injector.create({ providers: [RequestLogger], parent: root });
|
|
27
|
+
* child.resolve(RequestLogger); // gets Database from root
|
|
28
|
+
*/
|
|
29
|
+
export declare class Injector implements ContextInjector, AsyncDisposable {
|
|
30
|
+
#private;
|
|
31
|
+
/** Parent injector, or `null` for root injectors. */
|
|
32
|
+
readonly parent: Injector | null;
|
|
33
|
+
/** Debug label assigned at creation. Appears in error messages. */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** @internal Prefer `Injector.create()`. */
|
|
36
|
+
constructor(providers: Provider[], name: string, parent: Injector | null, defaultLifetime: Lifetime);
|
|
37
|
+
/**
|
|
38
|
+
* Create a root injector, or a child injector when `options.parent` is supplied.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* const injector = Injector.create({
|
|
42
|
+
* providers: [Database, UserService],
|
|
43
|
+
* });
|
|
44
|
+
*/
|
|
45
|
+
static create(options: InjectorOptions): Injector;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a token synchronously.
|
|
48
|
+
*
|
|
49
|
+
* Walks the injector chain, respects lifetimes, and detects captive dependencies.
|
|
50
|
+
* Throws `TokenNotFoundError` unless `{ optional: true }` is passed.
|
|
51
|
+
*/
|
|
52
|
+
resolve<T>(token: Token<T>): T;
|
|
53
|
+
resolve<T>(token: Token<T>, options: {
|
|
54
|
+
optional: true;
|
|
55
|
+
}): T | null;
|
|
56
|
+
resolve<T>(token: Token<T>, options?: InjectOptions): T | null;
|
|
57
|
+
private findBinding;
|
|
58
|
+
private hydrate;
|
|
59
|
+
/**
|
|
60
|
+
* Dispose all tracked instances in reverse construction order (LIFO), sequentially.
|
|
61
|
+
* Idempotent — concurrent calls share one run.
|
|
62
|
+
* On failure: a single error is rethrown as-is; multiple are wrapped in `AggregateError`.
|
|
63
|
+
*/
|
|
64
|
+
dispose(): Promise<void>;
|
|
65
|
+
/** Alias for `dispose()` — enables `await using injector = Injector.create(...)`. */
|
|
66
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
67
|
+
/** `true` after `dispose()` has been called, even if disposal threw. */
|
|
68
|
+
get disposed(): boolean;
|
|
69
|
+
}
|
package/dist/injector.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { CIRCULAR, EMPTY, makeBinding } from "./binding.js";
|
|
2
|
+
import { getInjectionContext, inject, setInjectionContext, } from "./context.js";
|
|
3
|
+
import { disposerOf, isDisposable } from "./disposable.js";
|
|
4
|
+
import { CaptiveDependencyError, CircularDependencyError, DependencyPathError, InjectorDisposedError, TokenNotFoundError, } from "./errors.js";
|
|
5
|
+
import { Lifetime, minLifetime } from "./lifetime.js";
|
|
6
|
+
/**
|
|
7
|
+
* IoC container. Holds provider bindings, caches instances by lifetime,
|
|
8
|
+
* and disposes tracked instances in reverse construction order on `dispose()`.
|
|
9
|
+
*
|
|
10
|
+
* Root injectors own singletons. Child injectors (via `parent`) own scoped
|
|
11
|
+
* instances and inherit every parent binding. Each injector disposes only what
|
|
12
|
+
* it constructed — parents and children are independent.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const root = Injector.create({ providers: [Database] });
|
|
16
|
+
* const child = Injector.create({ providers: [RequestLogger], parent: root });
|
|
17
|
+
* child.resolve(RequestLogger); // gets Database from root
|
|
18
|
+
*/
|
|
19
|
+
export class Injector {
|
|
20
|
+
#bindings;
|
|
21
|
+
#disposers;
|
|
22
|
+
/** Parent injector, or `null` for root injectors. */
|
|
23
|
+
parent;
|
|
24
|
+
/** Debug label assigned at creation. Appears in error messages. */
|
|
25
|
+
name;
|
|
26
|
+
#disposing;
|
|
27
|
+
/** @internal Prefer `Injector.create()`. */
|
|
28
|
+
constructor(providers, name, parent, defaultLifetime) {
|
|
29
|
+
if (parent != null)
|
|
30
|
+
throwIfDisposed(parent);
|
|
31
|
+
this.#bindings = new Map();
|
|
32
|
+
this.#disposers = [];
|
|
33
|
+
this.parent = parent;
|
|
34
|
+
this.name = name;
|
|
35
|
+
this.#disposing = null;
|
|
36
|
+
for (const provider of providers) {
|
|
37
|
+
const token = typeof provider === "function" ? provider : provider.provide;
|
|
38
|
+
this.#bindings.set(token, providerToBinding(provider, defaultLifetime));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create a root injector, or a child injector when `options.parent` is supplied.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const injector = Injector.create({
|
|
46
|
+
* providers: [Database, UserService],
|
|
47
|
+
* });
|
|
48
|
+
*/
|
|
49
|
+
static create(options) {
|
|
50
|
+
const parent = options.parent ?? null;
|
|
51
|
+
const defaultLifetime = options.defaultLifetime ?? Lifetime.Singleton;
|
|
52
|
+
const name = options.name ?? (parent ? `${parent.name}.child` : "root");
|
|
53
|
+
return new Injector(options.providers, name, parent, defaultLifetime);
|
|
54
|
+
}
|
|
55
|
+
resolve(token, options) {
|
|
56
|
+
const found = this.findBinding(token);
|
|
57
|
+
if (!found) {
|
|
58
|
+
if (options?.optional)
|
|
59
|
+
return null;
|
|
60
|
+
throw new TokenNotFoundError(token, this.name);
|
|
61
|
+
}
|
|
62
|
+
const { binding, injector } = found;
|
|
63
|
+
const prev = getInjectionContext();
|
|
64
|
+
// Singleton caches on owner; factory must run under owner's chain or child shadow poisons parent cache
|
|
65
|
+
const owner = binding.lifetime === Lifetime.Singleton && injector !== this
|
|
66
|
+
? injector
|
|
67
|
+
: this;
|
|
68
|
+
const prevInjectContext = setInjectionContext({
|
|
69
|
+
injector: owner,
|
|
70
|
+
effectiveLifetime: minLifetime(binding.lifetime, prev?.effectiveLifetime),
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
if (binding.lifetime === Lifetime.Scoped && injector !== this) {
|
|
74
|
+
const scopedBinding = makeBinding(binding.factory, EMPTY, Lifetime.Scoped);
|
|
75
|
+
this.#bindings.set(token, scopedBinding);
|
|
76
|
+
return this.hydrate(token, scopedBinding, options);
|
|
77
|
+
}
|
|
78
|
+
return injector.hydrate(token, binding, options);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
setInjectionContext(prevInjectContext);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
findBinding(token) {
|
|
85
|
+
let s = this;
|
|
86
|
+
while (s !== null) {
|
|
87
|
+
throwIfDisposed(s);
|
|
88
|
+
const b = s.#bindings.get(token);
|
|
89
|
+
if (b !== undefined) {
|
|
90
|
+
return { binding: b, injector: s };
|
|
91
|
+
}
|
|
92
|
+
s = s.parent;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
hydrate(token, binding, options) {
|
|
97
|
+
if (binding.value === CIRCULAR) {
|
|
98
|
+
throw new CircularDependencyError(token);
|
|
99
|
+
}
|
|
100
|
+
if (binding.value !== EMPTY) {
|
|
101
|
+
return binding.value;
|
|
102
|
+
}
|
|
103
|
+
const lifetime = binding.lifetime;
|
|
104
|
+
const ctx = getInjectionContext();
|
|
105
|
+
if (lifetime === Lifetime.Scoped &&
|
|
106
|
+
ctx.effectiveLifetime === Lifetime.Singleton) {
|
|
107
|
+
throw new CaptiveDependencyError(token);
|
|
108
|
+
}
|
|
109
|
+
binding.value = CIRCULAR;
|
|
110
|
+
let instance;
|
|
111
|
+
try {
|
|
112
|
+
instance = binding.factory(options);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
binding.value = EMPTY;
|
|
116
|
+
if (e instanceof DependencyPathError) {
|
|
117
|
+
DependencyPathError.prepend(e, token);
|
|
118
|
+
}
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
if (lifetime === Lifetime.Transient) {
|
|
122
|
+
binding.value = EMPTY;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
binding.value = instance;
|
|
126
|
+
if (isDisposable(instance)) {
|
|
127
|
+
this.#disposers.push(() => disposerOf(instance));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return instance;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Dispose all tracked instances in reverse construction order (LIFO), sequentially.
|
|
134
|
+
* Idempotent — concurrent calls share one run.
|
|
135
|
+
* On failure: a single error is rethrown as-is; multiple are wrapped in `AggregateError`.
|
|
136
|
+
*/
|
|
137
|
+
dispose() {
|
|
138
|
+
if (this.#disposing)
|
|
139
|
+
return this.#disposing;
|
|
140
|
+
this.#disposing = (async () => {
|
|
141
|
+
const errors = [];
|
|
142
|
+
// LIFO so consumers dispose before their deps.
|
|
143
|
+
for (let i = this.#disposers.length - 1; i >= 0; i--) {
|
|
144
|
+
try {
|
|
145
|
+
await this.#disposers[i]();
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
errors.push(e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.#disposers.length = 0;
|
|
152
|
+
this.#bindings.clear();
|
|
153
|
+
if (errors.length === 1)
|
|
154
|
+
throw errors[0];
|
|
155
|
+
if (errors.length > 1) {
|
|
156
|
+
throw new AggregateError(errors, `Dispose errors in injector "${this.name}"`);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
return this.#disposing;
|
|
160
|
+
}
|
|
161
|
+
/** Alias for `dispose()` — enables `await using injector = Injector.create(...)`. */
|
|
162
|
+
[Symbol.asyncDispose]() {
|
|
163
|
+
return this.dispose();
|
|
164
|
+
}
|
|
165
|
+
/** `true` after `dispose()` has been called, even if disposal threw. */
|
|
166
|
+
get disposed() {
|
|
167
|
+
return this.#disposing !== null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function providerToBinding(provider, defaultLifetime) {
|
|
171
|
+
let binding;
|
|
172
|
+
if (typeof provider === "function")
|
|
173
|
+
binding = makeBinding(() => new provider(), // class provider shorthand
|
|
174
|
+
EMPTY, defaultLifetime);
|
|
175
|
+
else if ("useValue" in provider)
|
|
176
|
+
binding = makeBinding(undefined, // hydrated immediately
|
|
177
|
+
provider.useValue, Lifetime.Singleton);
|
|
178
|
+
else if ("useFactory" in provider &&
|
|
179
|
+
typeof provider.useFactory === "function")
|
|
180
|
+
binding = makeBinding(provider.useFactory, EMPTY, provider.lifetime ?? defaultLifetime);
|
|
181
|
+
else if ("useClass" in provider && typeof provider.useClass === "function")
|
|
182
|
+
binding = makeBinding(() => new provider.useClass(), EMPTY, provider.lifetime ?? defaultLifetime);
|
|
183
|
+
else if ("useExisting" in provider && provider.useExisting != null)
|
|
184
|
+
binding = makeBinding((options) => inject(provider.useExisting, options), EMPTY,
|
|
185
|
+
// Alias re-dispatches every time; target's binding owns caching.
|
|
186
|
+
Lifetime.Transient);
|
|
187
|
+
else {
|
|
188
|
+
throw new TypeError(`Unknown provider type.`, {
|
|
189
|
+
cause: provider,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return binding;
|
|
193
|
+
}
|
|
194
|
+
function throwIfDisposed(injector) {
|
|
195
|
+
if (injector.disposed) {
|
|
196
|
+
throw new InjectorDisposedError(injector.name);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a resolved instance lives within its owning injector.
|
|
3
|
+
* Default when omitted from a provider: `Singleton`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const Lifetime: Readonly<{
|
|
6
|
+
/** Cached on the owning injector. One instance per binding site. */
|
|
7
|
+
Singleton: "singleton";
|
|
8
|
+
/** Cached per resolving child injector. One instance per child. */
|
|
9
|
+
Scoped: "scoped";
|
|
10
|
+
/** Fresh on every `resolve()` / `inject()`. Never cached, never disposed. */
|
|
11
|
+
Transient: "transient";
|
|
12
|
+
}>;
|
|
13
|
+
export type Lifetime = (typeof Lifetime)[keyof typeof Lifetime];
|
|
14
|
+
/** @internal Strictest of two lifetimes (Singleton < Scoped < Transient). */
|
|
15
|
+
export declare function minLifetime(a: Lifetime, b: Lifetime | undefined): Lifetime;
|
package/dist/lifetime.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a resolved instance lives within its owning injector.
|
|
3
|
+
* Default when omitted from a provider: `Singleton`.
|
|
4
|
+
*/
|
|
5
|
+
export const Lifetime = Object.freeze({
|
|
6
|
+
/** Cached on the owning injector. One instance per binding site. */
|
|
7
|
+
Singleton: "singleton",
|
|
8
|
+
/** Cached per resolving child injector. One instance per child. */
|
|
9
|
+
Scoped: "scoped",
|
|
10
|
+
/** Fresh on every `resolve()` / `inject()`. Never cached, never disposed. */
|
|
11
|
+
Transient: "transient",
|
|
12
|
+
});
|
|
13
|
+
const RANK = {
|
|
14
|
+
singleton: 0,
|
|
15
|
+
scoped: 1,
|
|
16
|
+
transient: 2,
|
|
17
|
+
};
|
|
18
|
+
/** @internal Strictest of two lifetimes (Singleton < Scoped < Transient). */
|
|
19
|
+
export function minLifetime(a, b) {
|
|
20
|
+
if (b === undefined)
|
|
21
|
+
return a;
|
|
22
|
+
return RANK[a] < RANK[b] ? a : b;
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Lifetime } from "./lifetime.ts";
|
|
2
|
+
import type { Constructor, Token } from "./token.ts";
|
|
3
|
+
/** Provide a pre-existing value. The injector never disposes it — the caller owns it. */
|
|
4
|
+
export interface ValueProvider<T = unknown> {
|
|
5
|
+
provide: Token<T>;
|
|
6
|
+
useValue: T;
|
|
7
|
+
}
|
|
8
|
+
/** Provide via a zero-arg factory. `inject()` is active inside the factory body. */
|
|
9
|
+
export interface FactoryProvider<T = unknown> {
|
|
10
|
+
provide: Token<T>;
|
|
11
|
+
useFactory: () => T;
|
|
12
|
+
lifetime?: Lifetime;
|
|
13
|
+
}
|
|
14
|
+
/** Provide by constructing a class. `inject()` is active in field initializers and the constructor. */
|
|
15
|
+
export interface ClassProvider<T = unknown> {
|
|
16
|
+
provide: Token<T>;
|
|
17
|
+
useClass: Constructor<T>;
|
|
18
|
+
lifetime?: Lifetime;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Alias: re-dispatch `provide` as `useExisting`.
|
|
22
|
+
* Resolution always goes through the calling injector, so child overrides are respected.
|
|
23
|
+
* Caching is owned by the target's binding.
|
|
24
|
+
*/
|
|
25
|
+
export interface ExistingProvider<T = unknown> {
|
|
26
|
+
provide: Token<T>;
|
|
27
|
+
useExisting: Token<T>;
|
|
28
|
+
}
|
|
29
|
+
export type Provider<T = unknown> = Constructor<T> | ValueProvider<T> | ClassProvider<T> | FactoryProvider<T> | ExistingProvider<T>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed DI key for non-class dependencies — interfaces, primitives, config objects.
|
|
3
|
+
* Unique by identity: two tokens with the same description are distinct keys.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* const API_URL = new InjectionToken<string>("API_URL");
|
|
7
|
+
* const TIMEOUT = new InjectionToken<number>("TIMEOUT");
|
|
8
|
+
* abstract class Cache { abstract get(k: string): string | null; }
|
|
9
|
+
*/
|
|
10
|
+
export declare class InjectionToken<T> {
|
|
11
|
+
readonly __type: T;
|
|
12
|
+
readonly description: string;
|
|
13
|
+
constructor(description: string);
|
|
14
|
+
toString(): string;
|
|
15
|
+
}
|
|
16
|
+
/** A concrete class constructor. */
|
|
17
|
+
export interface Constructor<T = unknown> {
|
|
18
|
+
new (...args: never[]): T;
|
|
19
|
+
}
|
|
20
|
+
/** A class or abstract class reference. */
|
|
21
|
+
export type AbstractClass<T = unknown> = abstract new (...args: never[]) => T;
|
|
22
|
+
/** Anything that can be used as a DI key. */
|
|
23
|
+
export type Token<T = unknown> = Constructor<T> | InjectionToken<T> | AbstractClass<T>;
|
|
24
|
+
/** @internal Returns a human-readable name for a token. */
|
|
25
|
+
export declare function tokenName(token: Token): string;
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed DI key for non-class dependencies — interfaces, primitives, config objects.
|
|
3
|
+
* Unique by identity: two tokens with the same description are distinct keys.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* const API_URL = new InjectionToken<string>("API_URL");
|
|
7
|
+
* const TIMEOUT = new InjectionToken<number>("TIMEOUT");
|
|
8
|
+
* abstract class Cache { abstract get(k: string): string | null; }
|
|
9
|
+
*/
|
|
10
|
+
export class InjectionToken {
|
|
11
|
+
description;
|
|
12
|
+
constructor(description) {
|
|
13
|
+
this.description = description;
|
|
14
|
+
}
|
|
15
|
+
toString() {
|
|
16
|
+
return `InjectionToken(${this.description})`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** @internal Returns a human-readable name for a token. */
|
|
20
|
+
export function tokenName(token) {
|
|
21
|
+
if (token instanceof InjectionToken)
|
|
22
|
+
return token.toString();
|
|
23
|
+
return token.name || String(token);
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "injectus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "High-
|
|
5
|
+
"description": "High-performance, decorator-free IoC container for Node.js — sync inject(), explicit lifetimes, TC39-native disposal.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
@@ -20,6 +20,21 @@
|
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=22.6.0"
|
|
22
22
|
},
|
|
23
|
+
"devEngines": {
|
|
24
|
+
"runtime": {
|
|
25
|
+
"name": "node",
|
|
26
|
+
"version": ">=24.16.0"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"dependency-injection",
|
|
31
|
+
"ioc-container",
|
|
32
|
+
"inversion-of-control",
|
|
33
|
+
"container",
|
|
34
|
+
"injector",
|
|
35
|
+
"inject",
|
|
36
|
+
"service-locator"
|
|
37
|
+
],
|
|
23
38
|
"license": "ISC",
|
|
24
39
|
"author": "Hossam Hamdy <hossamhamdy117@gmail.com> (https://github.com/hossam7amdy)",
|
|
25
40
|
"repository": {
|
|
@@ -42,6 +57,7 @@
|
|
|
42
57
|
"devDependencies": {
|
|
43
58
|
"@biomejs/biome": "^2.4.16",
|
|
44
59
|
"@types/node": "^25.9.1",
|
|
60
|
+
"lefthook": "^2.1.9",
|
|
45
61
|
"prettier": "^3.8.3",
|
|
46
62
|
"typescript": "^6.0.3"
|
|
47
63
|
},
|
|
@@ -49,9 +65,10 @@
|
|
|
49
65
|
"clean": "rm -rf dist",
|
|
50
66
|
"build": "tsc -p tsconfig.build.json",
|
|
51
67
|
"typecheck": "tsc -p tsconfig.json",
|
|
52
|
-
"test": "node --
|
|
53
|
-
"test:watch": "node --
|
|
54
|
-
"test:cov": "node --
|
|
68
|
+
"test": "node --test src/__tests__/*.test.ts",
|
|
69
|
+
"test:watch": "node --test --watch src/__tests__/*.test.ts",
|
|
70
|
+
"test:cov:node22": "node --test --experimental-test-coverage --test-coverage-exclude='src/__tests__/*' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 \"src/__tests__/!(*.esnext).test.ts\"",
|
|
71
|
+
"test:cov": "node --test --experimental-test-coverage --test-coverage-exclude='src/__tests__/*' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info src/__tests__/*.test.ts",
|
|
55
72
|
"preversion": "pnpm clean && pnpm build && pnpm check && pnpm typecheck && pnpm test:cov",
|
|
56
73
|
"check": "biome check . && prettier --check \"**/*.{md,yaml}\"",
|
|
57
74
|
"check:fix": "biome check . --write && prettier --write \"**/*.{md,yaml}\"",
|