injectus 0.1.1-alpha.0 → 0.2.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 CHANGED
@@ -1,3 +1,322 @@
1
1
  # Injectus
2
2
 
3
- High-performant, decorator-free IoC container for Node.js — sync inject(), explicit lifetimes, TC39-native disposal.
3
+ [![npm](https://img.shields.io/npm/v/injectus.svg?maxAge=1000)](https://www.npmjs.com/package/injectus)
4
+ [![CI](https://github.com/hossam7amdy/injectus/actions/workflows/ci.yaml/badge.svg)](https://github.com/hossam7amdy/injectus/actions/workflows/ci.yaml)
5
+ [![coveralls](https://coveralls.io/repos/github/hossam7amdy/injectus/badge.svg)](https://coveralls.io/github/hossam7amdy/injectus)
6
+ [![npm](https://img.shields.io/npm/l/injectus.svg?maxAge=1000)](https://github.com/hossam7amdy/injectus/blob/main/LICENSE)
7
+ [![node](https://img.shields.io/node/v/injectus.svg?maxAge=1000)](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>;
@@ -0,0 +1,7 @@
1
+ /** No cached value yet. */
2
+ export const EMPTY = Symbol("EMPTY");
3
+ /** Hydration in progress — cycle marker. */
4
+ export const CIRCULAR = Symbol("CIRCULAR");
5
+ export function makeBinding(factory, value, lifetime) {
6
+ return { factory, value, lifetime };
7
+ }
@@ -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;
@@ -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
+ }
@@ -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
+ /** @internal 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
+ /** @internal 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
- export {};
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
+ }
@@ -0,0 +1,196 @@
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 prevLifetime = getInjectionContext()?.effectiveLifetime;
64
+ if (prevLifetime === Lifetime.Singleton &&
65
+ binding.lifetime === Lifetime.Scoped) {
66
+ throw new CaptiveDependencyError(token);
67
+ }
68
+ const isNotSelf = injector !== this;
69
+ const owner =
70
+ // Singleton caches on owner; factory must run under owner's chain or child shadow poisons parent cache
71
+ isNotSelf && binding.lifetime === Lifetime.Singleton ? injector : this;
72
+ const prevInjectContext = setInjectionContext({
73
+ injector: owner,
74
+ effectiveLifetime: minLifetime(binding.lifetime, prevLifetime),
75
+ });
76
+ try {
77
+ if (isNotSelf && binding.lifetime === Lifetime.Scoped) {
78
+ const scopedBinding = makeBinding(binding.factory, EMPTY, Lifetime.Scoped);
79
+ this.#bindings.set(token, scopedBinding);
80
+ return this.hydrate(token, scopedBinding, options);
81
+ }
82
+ return injector.hydrate(token, binding, options);
83
+ }
84
+ finally {
85
+ setInjectionContext(prevInjectContext);
86
+ }
87
+ }
88
+ findBinding(token) {
89
+ let s = this;
90
+ while (s !== null) {
91
+ throwIfDisposed(s);
92
+ const b = s.#bindings.get(token);
93
+ if (b !== undefined) {
94
+ return { binding: b, injector: s };
95
+ }
96
+ s = s.parent;
97
+ }
98
+ return null;
99
+ }
100
+ hydrate(token, binding, options) {
101
+ if (binding.value === CIRCULAR) {
102
+ throw new CircularDependencyError(token);
103
+ }
104
+ if (binding.value !== EMPTY) {
105
+ return binding.value;
106
+ }
107
+ binding.value = CIRCULAR;
108
+ let instance;
109
+ try {
110
+ instance = binding.factory(options);
111
+ }
112
+ catch (e) {
113
+ binding.value = EMPTY;
114
+ if (e instanceof DependencyPathError) {
115
+ DependencyPathError.prepend(e, token);
116
+ }
117
+ throw e;
118
+ }
119
+ if (binding.lifetime === Lifetime.Transient) {
120
+ binding.value = EMPTY;
121
+ }
122
+ else {
123
+ binding.value = instance;
124
+ if (isDisposable(instance)) {
125
+ this.#disposers.push(() => disposerOf(instance));
126
+ }
127
+ }
128
+ return instance;
129
+ }
130
+ /**
131
+ * Dispose all tracked instances in reverse construction order (LIFO), sequentially.
132
+ * Idempotent — concurrent calls share one run.
133
+ * On failure: a single error is rethrown as-is; multiple are wrapped in `AggregateError`.
134
+ */
135
+ dispose() {
136
+ if (this.#disposing)
137
+ return this.#disposing;
138
+ this.#disposing = (async () => {
139
+ const errors = [];
140
+ // LIFO so consumers dispose before their deps.
141
+ for (let i = this.#disposers.length - 1; i >= 0; i--) {
142
+ try {
143
+ await this.#disposers[i]();
144
+ }
145
+ catch (e) {
146
+ errors.push(e);
147
+ }
148
+ }
149
+ this.#disposers.length = 0;
150
+ this.#bindings.clear();
151
+ if (errors.length === 1)
152
+ throw errors[0];
153
+ if (errors.length > 1) {
154
+ throw new AggregateError(errors, `Dispose errors in injector "${this.name}"`);
155
+ }
156
+ })();
157
+ return this.#disposing;
158
+ }
159
+ /** Alias for `dispose()` — enables `await using injector = Injector.create(...)`. */
160
+ [Symbol.asyncDispose]() {
161
+ return this.dispose();
162
+ }
163
+ /** `true` after `dispose()` has been called, even if disposal threw. */
164
+ get disposed() {
165
+ return this.#disposing !== null;
166
+ }
167
+ }
168
+ function providerToBinding(provider, defaultLifetime) {
169
+ let binding;
170
+ if (typeof provider === "function")
171
+ binding = makeBinding(() => new provider(), // class provider shorthand
172
+ EMPTY, defaultLifetime);
173
+ else if ("useValue" in provider)
174
+ binding = makeBinding(undefined, // hydrated immediately
175
+ provider.useValue, Lifetime.Singleton);
176
+ else if ("useFactory" in provider &&
177
+ typeof provider.useFactory === "function")
178
+ binding = makeBinding(provider.useFactory, EMPTY, provider.lifetime ?? defaultLifetime);
179
+ else if ("useClass" in provider && typeof provider.useClass === "function")
180
+ binding = makeBinding(() => new provider.useClass(), EMPTY, provider.lifetime ?? defaultLifetime);
181
+ else if ("useExisting" in provider && provider.useExisting != null)
182
+ binding = makeBinding((options) => inject(provider.useExisting, options), EMPTY,
183
+ // Alias re-dispatches every time; target's binding owns caching.
184
+ Lifetime.Transient);
185
+ else {
186
+ throw new TypeError(`Unknown provider type.`, {
187
+ cause: provider,
188
+ });
189
+ }
190
+ return binding;
191
+ }
192
+ function throwIfDisposed(injector) {
193
+ if (injector.disposed) {
194
+ throw new InjectorDisposedError(injector.name);
195
+ }
196
+ }
@@ -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;
@@ -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>;
@@ -0,0 +1 @@
1
+ export {};
@@ -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.1.1-alpha.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
- "description": "High-performant, decorator-free IoC container for Node.js — sync inject(), explicit lifetimes, TC39-native disposal.",
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 --import=tsx --test src/__tests__/*.test.ts",
53
- "test:watch": "node --import=tsx --test --watch src/__tests__/*.test.ts",
54
- "test:cov": "node --import=tsx --test --experimental-test-coverage --test-coverage-exclude='src/__tests__/*' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 src/__tests__/*.test.ts",
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}\"",