Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules
dist
.DS_Store
.vscode/symbols.json
artifacts
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- `iNodeTokenBaseOptions.global` — opt-in token-instance deduplication by name via
a `globalThis` registry (mirrors the existing token-class dedup). Identically-named
global tokens constructed in separately-bundled modules resolve to the same instance,
so the container (which keys providers by reference) treats them as one. Enables a
dynamically-imported plugin to bind a host's seam tokens without sharing a bundle.

### Changed

- The three remaining raw `throw new Error` sites now throw a typed `InjectionError`
carrying a stable code, matching the rest of the library: a global-token kind conflict
(`i600`), a middleware calling `next()` more than once (`i700`), and the internal
unknown-`ProtoNode` invariant (`i800`). Messages are unchanged apart from the `[iNNN]`
prefix, and callers can now branch on `error.code` for these cases.

### Fixed

- Tree-node resolution no longer leaves a node stranded as "in progress" when a factory
or dependency throws. A failed resolution now resets its guard on every exit path, so a
retried `get()` (lazy `instant: false` mode) or another consumer of a shared dependency
re-runs the factory instead of reporting a bogus circular-dependency (`i401`) error.
`MultiNodeToken` resolution also resets its members per attempt so a retry cannot
accumulate duplicates.

### Documentation

- Completed the error reference (`TROUBLESHOOTING.md`): documented `i202`, `i304`, `i305`,
and the new `i600`/`i700`/`i800` codes across the quick-reference table, table of contents,
and detail sections; documented the `singleton` and `global` token options in `TOKENS.md`
and `API.md`.
- Corrected stale examples: plugin imports now use the `@illuma/core/plugins` subpath
(`Illuma`, `iMiddleware`, the diagnostics types, `iContextScanner`); fixed the `nodeInject`
signature and `@NodeInjectable()` usage in `TECHNICAL_OVERVIEW.md`, the `iSpectator<T>`
name and a malformed provider array in `TESTKIT.md`, and a request-scoped `injectGroupAsync`
example in `ASYNC_INJECTION.md`.

## 2.3.0 - 2026-05-28

## 2.2.0 - 2026-05-10
Expand Down
25 changes: 0 additions & 25 deletions artifacts/coverage/coverage-summary.json

This file was deleted.

11 changes: 10 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ new NodeToken<T>(
options?: {
factory?: () => T;
singleton?: boolean;
global?: boolean;
}
)
```
Expand All @@ -158,6 +159,7 @@ new NodeToken<T>(
| `name` | `string` | Unique identifier for the token |
| `options.factory` | `() => T` | Optional factory for default value |
| `options.singleton` | `boolean` | Marks token as root-scoped singleton in parent-child containers |
| `options.global` | `boolean` | Dedupe this token by name in a process-global registry, so an identically-named global token from another bundle is the same instance. Reserve for cross-bundle seam tokens; the name becomes a global identity and must be unique per kind (see [i600](./TROUBLESHOOTING.md#token-errors)) |

When `singleton: true`, there's no need to call `provide` for this token. It will be automatically provided as a singleton in the root container when first requested until you want to override it in a child container.

Expand Down Expand Up @@ -207,7 +209,14 @@ A token that can have multiple providers, returning an array.
### Constructor

```typescript
new MultiNodeToken<T>(name: string, options?: { factory?: () => T })
new MultiNodeToken<T>(
name: string,
options?: {
factory?: () => T;
singleton?: boolean;
global?: boolean;
}
)
```

### Usage
Expand Down
29 changes: 20 additions & 9 deletions docs/ASYNC_INJECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ This guide covers advanced dependency injection patterns using `injectAsync`, `i

Illuma provides three utilities for advanced async dependency injection:

| Utility | Purpose | Returns |
|---------|---------|---------|
| `injectAsync` | Lazily inject a single dependency | The dependency instance |
| `injectEntryAsync` | Create sub-container and resolve specific entrypoint | The entrypoint instance |
| `injectGroupAsync` | Create isolated sub-container with array of providers | An injector |
| Utility | Purpose | Returns |
| ------------------ | ----------------------------------------------------- | ----------------------- |
| `injectAsync` | Lazily inject a single dependency | The dependency instance |
| `injectEntryAsync` | Create sub-container and resolve specific entrypoint | The entrypoint instance |
| `injectGroupAsync` | Create isolated sub-container with array of providers | An injector |

**Note:** For synchronous deferred injection (e.g. to solve circular dependencies without async/await), use [`injectDefer`](./API.md#injectDefer) instead.

Expand Down Expand Up @@ -185,6 +185,8 @@ private readonly getAnalytics = injectAsync(
);
```

> **Lifecycle note for `withCache: false`.** Each call creates a fresh sub-container that is tied to the parent container's lifecycle and is destroyed when the parent is destroyed. The parent therefore accumulates one sub-container per call until it is destroyed — by design, since you opted out of caching. For request-scoped work on a long-lived parent where you need to release each scope eagerly, use `injectGroupAsync` (which returns the sub-container's `Injector`) and call `injector.destroy()` when the scope ends, or create and destroy a child container explicitly. `injectAsync` / `injectEntryAsync` return the produced instance only, so they intentionally do not expose a per-call disposal handle.

### Overriding dependencies

Provide additional dependencies or override parent container values:
Expand Down Expand Up @@ -467,11 +469,20 @@ const REQUEST_CONTEXT = new NodeToken<RequestContext>('REQUEST_CONTEXT');

@NodeInjectable()
class RequestHandler {
// Capture the injector during construction, while the injection context is
// open. `injectGroupAsync` resolves `Injector` eagerly when it is *called*,
// so invoking it later from a request handler (outside the context) would
// otherwise throw an "outside of an injection context" error ([i501]).
private readonly injector = nodeInject(Injector);

public createRequestScope(userId: string, requestId: string) {
return injectGroupAsync(async () => [
RequestService,
{ provide: REQUEST_CONTEXT, value: { userId, requestId } }
], { withCache: false }); // New scope per request
return injectGroupAsync(
async () => [
RequestService,
{ provide: REQUEST_CONTEXT, value: { userId, requestId } },
],
{ injector: this.injector, withCache: false }, // new scope per request
);
}

public async handleRequest(userId: string, requestId: string) {
Expand Down
47 changes: 25 additions & 22 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Illuma provides a plugin system that allows you to extend its core functionality
The `Illuma` class is the central hub for managing plugins in Illuma. It provides static methods to register plugins globally, which will then be automatically invoked at the appropriate times during the container lifecycle.

```typescript
import { Illuma } from '@illuma/core';
import { Illuma } from '@illuma/core/plugins';

// Register a context scanner
Illuma.extendContextScanner(myScanner);
Expand Down Expand Up @@ -136,17 +136,19 @@ Diagnostics modules analyze the container state after bootstrap and provide insi
A diagnostics module must implement the `iDiagnosticsModule` interface:

```typescript
import type { TreeNode } from '@illuma/core';

interface iDiagnosticsReport {
readonly totalNodes: number; // Total dependency nodes in container
readonly unusedNodes: TreeNode<unknown>[]; // Nodes that weren't resolved
readonly bootstrapDuration: number; // Bootstrap time in milliseconds
}

interface iDiagnosticsModule {
readonly onReport: (report: iDiagnosticsReport) => void;
}
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins';

// Both interfaces are exported from '@illuma/core/plugins'. Their shape:
//
// interface iDiagnosticsReport {
// readonly totalNodes: number; // Total dependency nodes in container
// readonly unusedNodes: TreeNode<unknown>[]; // Nodes never resolved (TreeNode is internal)
// readonly bootstrapDuration: number; // Bootstrap time in milliseconds
// }
//
// interface iDiagnosticsModule {
// readonly onReport: (report: iDiagnosticsReport) => void;
// }
```

**Report fields:**
Expand All @@ -160,7 +162,7 @@ interface iDiagnosticsModule {
#### Example: Custom Performance Reporter

```typescript
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core';
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins';

export class PerformanceReporter implements iDiagnosticsModule {
public onReport(report: iDiagnosticsReport): void {
Expand All @@ -186,7 +188,7 @@ export class PerformanceReporter implements iDiagnosticsModule {
Throw an error if any dependencies are unused (strict mode):

```typescript
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core';
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins';

export class StrictUnusedValidator implements iDiagnosticsModule {
public onReport(report: iDiagnosticsReport): void {
Expand All @@ -208,7 +210,7 @@ export class StrictUnusedValidator implements iDiagnosticsModule {
Send diagnostics to a logging service:

```typescript
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core';
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins';

export class JsonDiagnosticsLogger implements iDiagnosticsModule {
constructor(private readonly loggerService: LoggerService) {}
Expand Down Expand Up @@ -246,9 +248,9 @@ export class JsonDiagnosticsLogger implements iDiagnosticsModule {
Diagnostics modules should be registered before bootstrapping the container. To enable the diagnostics system, you must call `enableIllumaDiagnostics()` from `@illuma/core/plugins`:

```typescript
import { Illuma, NodeContainer } from '@illuma/core';
import { NodeContainer } from '@illuma/core';
import { PerformanceReporter } from './diagnostics';
import { enableIllumaDiagnostics } from '@illuma/core/plugins';
import { Illuma, enableIllumaDiagnostics } from '@illuma/core/plugins';

// 1. Enable diagnostics system
enableIllumaDiagnostics();
Expand Down Expand Up @@ -334,7 +336,7 @@ type iMiddleware<T = unknown> = (
Log every time a dependency is instantiated:

```typescript
import type { iMiddleware } from '@illuma/core';
import type { iMiddleware } from '@illuma/core/plugins';

export const loggerMiddleware: iMiddleware = (params, next) => {
console.log(`[Middleware] Creating instance of: ${params.token.name}`);
Expand All @@ -354,7 +356,7 @@ export const loggerMiddleware: iMiddleware = (params, next) => {
Automatically wrap certain services in a Proxy:

```typescript
import type { iMiddleware } from '@illuma/core';
import type { iMiddleware } from '@illuma/core/plugins';

export const proxyMiddleware: iMiddleware = (params, next) => {
const instance = next(params);
Expand All @@ -380,7 +382,7 @@ export const proxyMiddleware: iMiddleware = (params, next) => {
Affects **all** containers created thereafter.

```typescript
import { Illuma } from '@illuma/core';
import { Illuma } from '@illuma/core/plugins';

Illuma.registerGlobalMiddleware(loggerMiddleware);
```
Expand Down Expand Up @@ -446,7 +448,8 @@ export class OptimizedScanner implements iContextScanner {
Support property-based injection using decorators:

```typescript
import type { iContextScanner, NodeToken, iInjectionNode } from '@illuma/core';
import type { iContextScanner } from '@illuma/core/plugins';
import type { NodeToken, iInjectionNode } from '@illuma/core';

const PROPERTY_INJECT_KEY = Symbol('di:properties');

Expand Down Expand Up @@ -496,7 +499,7 @@ Only report diagnostics in development mode:

```typescript
import { enableIllumaDiagnostics } from '@illuma/core/plugins';
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core';
import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins';

export class ConditionalReporter implements iDiagnosticsModule {
constructor(
Expand Down
Loading