From d87af3ddbc298d792da1e5e61de0cea1ea0e7377 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 11:25:59 +0200 Subject: [PATCH 01/17] feat(shared): add LazyModule type and PROVIDER_TOKEN_CONFLICT graph error Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../src/interfaces/dynamic-module.interface.ts | 3 ++- .../src/interfaces/graph-error.interface.ts | 6 ++++++ packages/shared/src/interfaces/index.ts | 1 + .../src/interfaces/lazy-module.interface.ts | 15 +++++++++++++++ .../src/interfaces/module-container.interface.ts | 3 +++ 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/interfaces/lazy-module.interface.ts diff --git a/packages/shared/src/interfaces/dynamic-module.interface.ts b/packages/shared/src/interfaces/dynamic-module.interface.ts index ff1df437..4b201b30 100644 --- a/packages/shared/src/interfaces/dynamic-module.interface.ts +++ b/packages/shared/src/interfaces/dynamic-module.interface.ts @@ -1,4 +1,5 @@ import type { InjectionToken } from "./injection-token.interface"; +import type { LazyModule } from "./lazy-module.interface"; import type { Module, Provider } from "./module-types.interface"; export interface DynamicModule { @@ -10,5 +11,5 @@ export interface DynamicModule { }; providers?: Provider[]; exports?: InjectionToken[]; - imports?: (Module | DynamicModule)[]; + imports?: (Module | DynamicModule | LazyModule)[]; } diff --git a/packages/shared/src/interfaces/graph-error.interface.ts b/packages/shared/src/interfaces/graph-error.interface.ts index 7ad0e019..41d8c189 100644 --- a/packages/shared/src/interfaces/graph-error.interface.ts +++ b/packages/shared/src/interfaces/graph-error.interface.ts @@ -26,4 +26,10 @@ export type GraphError = token: string; dependency: string; key: number; + } + | { + type: "PROVIDER_TOKEN_CONFLICT"; + token: string; + module: string; + existingModule: string; }; diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 875d21da..29f90cf2 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -5,6 +5,7 @@ export * from "./forward-ref.interface"; export * from "./graph-error.interface"; export * from "./hash-util.interface"; export * from "./injection-token.interface"; +export * from "./lazy-module.interface"; export * from "./module-container.interface"; export * from "./module-graph.interface"; export * from "./module-types.interface"; diff --git a/packages/shared/src/interfaces/lazy-module.interface.ts b/packages/shared/src/interfaces/lazy-module.interface.ts new file mode 100644 index 00000000..90314138 --- /dev/null +++ b/packages/shared/src/interfaces/lazy-module.interface.ts @@ -0,0 +1,15 @@ +import type { DynamicModule } from "./dynamic-module.interface"; +import type { Module } from "./module-types.interface"; + +/** + * A lazily loaded module import. Created with `lazy()` from `@nexus-ioc/core`. + * The module class is not imported until `NexusApplication.load(ref)` runs. + */ +export interface LazyModule { + /** Unique identity of this lazy boundary; used as the graph node key. */ + readonly id: symbol; + /** Human-readable name for diagnostics and chunk naming. */ + readonly name: string; + /** Runs the dynamic import and returns the module class or a DynamicModule. */ + readonly load: () => Promise; +} diff --git a/packages/shared/src/interfaces/module-container.interface.ts b/packages/shared/src/interfaces/module-container.interface.ts index 2e550c6a..87586444 100644 --- a/packages/shared/src/interfaces/module-container.interface.ts +++ b/packages/shared/src/interfaces/module-container.interface.ts @@ -1,12 +1,15 @@ import type { DynamicModule } from "./dynamic-module.interface"; import type { GraphError } from "./graph-error.interface"; import type { InjectionToken } from "./injection-token.interface"; +import type { LazyModule } from "./lazy-module.interface"; import type { Module, Provider } from "./module-types.interface"; export interface ModuleContainerInterface { token: string; metatype: Module | DynamicModule; imports: Promise; + /** Lazy imports declared by this module; never registered until loaded. */ + lazyImports: LazyModule[]; providers: Provider[]; exports: (InjectionToken | Module)[]; get(token: InjectionToken): Promise; From 4f056b061c4631c8e3bc492e45cdf7168d946736 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 11:37:22 +0200 Subject: [PATCH 02/17] feat(core): add lazy() module import marker Added lazy() factory function and isLazyModule() type guard to support lazily loaded modules. Updated ModuleMetadata and DynamicModule interfaces to accept LazyModule in imports. Updated ModuleContainer to filter out lazy modules from regular imports (handled separately by lazyImports in Task 3). Added PROVIDER_TOKEN_CONFLICT error case to bootstrap-error.ts. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/lazy-marker.spec.ts | 42 +++++++++++++++++++ .../ioc/src/core/modules/module-container.ts | 7 ++-- packages/ioc/src/errors/bootstrap-error.ts | 2 + packages/ioc/src/index.ts | 2 + .../interfaces/dynamic-module.interface.ts | 3 +- .../interfaces/module-metadata.interface.ts | 5 ++- packages/ioc/src/utils/lazy-module.ts | 41 ++++++++++++++++++ 7 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 packages/ioc/__test__/lazy/lazy-marker.spec.ts create mode 100644 packages/ioc/src/utils/lazy-module.ts diff --git a/packages/ioc/__test__/lazy/lazy-marker.spec.ts b/packages/ioc/__test__/lazy/lazy-marker.spec.ts new file mode 100644 index 00000000..6ded2342 --- /dev/null +++ b/packages/ioc/__test__/lazy/lazy-marker.spec.ts @@ -0,0 +1,42 @@ +import "reflect-metadata"; +import { isLazyModule, lazy, Module } from "../../src"; + +describe("lazy()", () => { + @Module({}) + class FeatureModule {} + + it("creates a marker with a unique symbol id and a name", () => { + const ref = lazy(async () => FeatureModule, { name: "Feature" }); + expect(typeof ref.id).toBe("symbol"); + expect(ref.name).toBe("Feature"); + expect(isLazyModule(ref)).toBe(true); + }); + + it("defaults the name to 'LazyModule' and gives each call its own id", () => { + const a = lazy(async () => FeatureModule); + const b = lazy(async () => FeatureModule); + expect(a.name).toBe("LazyModule"); + expect(a.id).not.toBe(b.id); + }); + + it("does not run the loader on creation", async () => { + const loader = vi.fn(async () => FeatureModule); + const ref = lazy(loader); + expect(loader).not.toHaveBeenCalled(); + expect(await ref.load()).toBe(FeatureModule); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it("rejects non-markers", () => { + expect(isLazyModule(FeatureModule)).toBe(false); + expect(isLazyModule({ id: "x", load: () => 1 })).toBe(false); + expect(isLazyModule(null)).toBe(false); + }); + + it("is accepted by @Module imports without registering anything", () => { + const ref = lazy(async () => FeatureModule); + @Module({ imports: [ref] }) + class AppModule {} + expect(Reflect.getMetadata("imports", AppModule)).toEqual([ref]); + }); +}); diff --git a/packages/ioc/src/core/modules/module-container.ts b/packages/ioc/src/core/modules/module-container.ts index b9a7f75d..36ddd462 100644 --- a/packages/ioc/src/core/modules/module-container.ts +++ b/packages/ioc/src/core/modules/module-container.ts @@ -3,6 +3,7 @@ import type { ContainerBaseInterface, DynamicModule, InjectionToken, + LazyModule, ModuleContainerInterface, Provider, Type, @@ -31,7 +32,7 @@ export class ModuleContainer implements ModuleContainerInterface { } public get imports(): Promise { - let modules: (Type | DynamicModule)[]; + let modules: (Type | DynamicModule | LazyModule)[]; if (isDynamicModule(this.metatype)) { modules = this.metatype.imports || []; } else { @@ -43,8 +44,8 @@ export class ModuleContainer implements ModuleContainerInterface { return new Promise((resolved) => { async function run() { const imports = await Promise.all( - modules.map((item: Type | DynamicModule) => { - return self.container.addModule(item); + modules.map((item: Type | DynamicModule | LazyModule) => { + return self.container.addModule(item as Type | DynamicModule); }), ); diff --git a/packages/ioc/src/errors/bootstrap-error.ts b/packages/ioc/src/errors/bootstrap-error.ts index d5936693..57968ec9 100644 --- a/packages/ioc/src/errors/bootstrap-error.ts +++ b/packages/ioc/src/errors/bootstrap-error.ts @@ -20,6 +20,8 @@ function formatError(error: GraphError): string { return ` Missing provider "${error.dependency}" in ${error.token} (property "${error.key}")`; case "UNREACHED_DEP_FACTORY": return ` Missing provider "${error.dependency}" in ${error.token} (inject[${error.key}])`; + case "PROVIDER_TOKEN_CONFLICT": + return ` Provider token "${error.token}" in ${error.module} is already registered by ${error.existingModule}`; } } diff --git a/packages/ioc/src/index.ts b/packages/ioc/src/index.ts index 1e27d978..45d059dd 100644 --- a/packages/ioc/src/index.ts +++ b/packages/ioc/src/index.ts @@ -11,3 +11,5 @@ export * from "./interfaces"; export type { ForwardRef } from "./utils/forward-ref"; export { forwardRef } from "./utils/forward-ref"; export * from "./utils/helpers"; +export type { LazyModuleOptions } from "./utils/lazy-module"; +export { isLazyModule, lazy } from "./utils/lazy-module"; diff --git a/packages/ioc/src/interfaces/dynamic-module.interface.ts b/packages/ioc/src/interfaces/dynamic-module.interface.ts index 3c2b1a81..bc7db298 100644 --- a/packages/ioc/src/interfaces/dynamic-module.interface.ts +++ b/packages/ioc/src/interfaces/dynamic-module.interface.ts @@ -1,3 +1,4 @@ +import type { LazyModule } from "@nexus-ioc/shared"; import type { InjectionToken } from "./injection-token.interface"; import type { Provider } from "./module-types.interface"; import type { Type } from "./type.interface"; @@ -11,5 +12,5 @@ export interface DynamicModule { }; providers?: Provider[]; exports?: InjectionToken[]; - imports?: (Type | DynamicModule)[]; + imports?: (Type | DynamicModule | LazyModule)[]; } diff --git a/packages/ioc/src/interfaces/module-metadata.interface.ts b/packages/ioc/src/interfaces/module-metadata.interface.ts index f9436799..99c23e27 100644 --- a/packages/ioc/src/interfaces/module-metadata.interface.ts +++ b/packages/ioc/src/interfaces/module-metadata.interface.ts @@ -1,10 +1,13 @@ +import type { LazyModule } from "@nexus-ioc/shared"; import type { DynamicModule } from "./dynamic-module.interface"; import type { InjectionToken } from "./injection-token.interface"; import type { Provider } from "./module-types.interface"; import type { Type } from "./type.interface"; +export type { LazyModule } from "@nexus-ioc/shared"; + export interface ModuleMetadata { - imports?: (Type | DynamicModule)[]; + imports?: (Type | DynamicModule | LazyModule)[]; exports?: (InjectionToken | Type)[]; providers?: Provider[]; } diff --git a/packages/ioc/src/utils/lazy-module.ts b/packages/ioc/src/utils/lazy-module.ts new file mode 100644 index 00000000..6fcd3734 --- /dev/null +++ b/packages/ioc/src/utils/lazy-module.ts @@ -0,0 +1,41 @@ +import type { DynamicModule, LazyModule, Type } from "../interfaces"; + +export interface LazyModuleOptions { + /** Name used in diagnostics and, by the Vite plugin, for chunk naming. */ + name?: string; +} + +/** + * Declares a lazily loaded module import. + * + * @example + * ```typescript + * export const FeatureLazy = lazy(() => + * import("./feature/feature.module").then((m) => m.FeatureModule), + * ); + * + * @Module({ imports: [CoreModule, FeatureLazy] }) + * class AppModule {} + * ``` + */ +export function lazy( + loader: () => Promise, + options: LazyModuleOptions = {}, +): LazyModule { + const name = options.name ?? "LazyModule"; + return Object.freeze({ + id: Symbol(name), + name, + load: loader, + }); +} + +export function isLazyModule(value: unknown): value is LazyModule { + return ( + typeof value === "object" && + value !== null && + typeof (value as LazyModule).id === "symbol" && + typeof (value as LazyModule).load === "function" && + typeof (value as LazyModule).name === "string" + ); +} From 8fa0333598476138a236c1360af8549234bb2167 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 11:46:20 +0200 Subject: [PATCH 03/17] feat(core): skip lazy imports during module registration Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../lazy/module-container-lazy.spec.ts | 45 +++++++++++++++++++ .../ioc/src/core/modules/module-container.ts | 26 ++++++----- 2 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 packages/ioc/__test__/lazy/module-container-lazy.spec.ts diff --git a/packages/ioc/__test__/lazy/module-container-lazy.spec.ts b/packages/ioc/__test__/lazy/module-container-lazy.spec.ts new file mode 100644 index 00000000..115236e1 --- /dev/null +++ b/packages/ioc/__test__/lazy/module-container-lazy.spec.ts @@ -0,0 +1,45 @@ +import "reflect-metadata"; +import { lazy, Module } from "../../src"; +import { Container } from "../../src/core/modules/container"; +import { HashUtil } from "../../src/utils/hash-utils"; + +describe("ModuleContainer with lazy imports", () => { + @Module({}) + class EagerModule {} + + @Module({}) + class FeatureModule {} + + it("registers eager imports but not lazy ones", async () => { + const loader = vi.fn(async () => FeatureModule); + const FeatureLazy = lazy(loader, { name: "Feature" }); + + @Module({ imports: [EagerModule, FeatureLazy] }) + class AppModule {} + + const container = new Container(new HashUtil()); + const app = await container.addModule(AppModule); + + const imports = await app.imports; + expect(imports.map((m) => m.metatype)).toEqual([EagerModule]); + expect(app.lazyImports).toEqual([FeatureLazy]); + expect(loader).not.toHaveBeenCalled(); + expect(await container.getModule(FeatureModule)).toBeUndefined(); + }); + + it("reads lazy imports from a DynamicModule too", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + + @Module({}) + class ConfigModule { + static forRoot() { + return { module: ConfigModule, imports: [FeatureLazy] }; + } + } + + const container = new Container(new HashUtil()); + const mc = await container.addModule(ConfigModule.forRoot()); + expect(await mc.imports).toEqual([]); + expect(mc.lazyImports).toEqual([FeatureLazy]); + }); +}); diff --git a/packages/ioc/src/core/modules/module-container.ts b/packages/ioc/src/core/modules/module-container.ts index 36ddd462..133c42cb 100644 --- a/packages/ioc/src/core/modules/module-container.ts +++ b/packages/ioc/src/core/modules/module-container.ts @@ -10,6 +10,7 @@ import type { } from "../../interfaces"; import { MODULE_METADATA } from "../../interfaces"; import { isDynamicModule } from "../../utils/helpers"; +import { isLazyModule } from "../../utils/lazy-module"; export class ModuleContainer implements ModuleContainerInterface { private _token = ""; @@ -31,31 +32,34 @@ export class ModuleContainer implements ModuleContainerInterface { return this._metatype; } - public get imports(): Promise { - let modules: (Type | DynamicModule | LazyModule)[]; + private get declaredImports(): (Type | DynamicModule | LazyModule)[] { if (isDynamicModule(this.metatype)) { - modules = this.metatype.imports || []; - } else { - modules = - Reflect.getMetadata(MODULE_METADATA.IMPORTS, this.metatype) || []; + return this.metatype.imports || []; } + return Reflect.getMetadata(MODULE_METADATA.IMPORTS, this.metatype) || []; + } + + public get imports(): Promise { + const modules = this.declaredImports.filter( + (item): item is Type | DynamicModule => !isLazyModule(item), + ); const self = this; return new Promise((resolved) => { async function run() { const imports = await Promise.all( - modules.map((item: Type | DynamicModule | LazyModule) => { - return self.container.addModule(item as Type | DynamicModule); - }), + modules.map((item) => self.container.addModule(item)), ); - resolved(imports); } - run(); }); } + public get lazyImports(): LazyModule[] { + return this.declaredImports.filter(isLazyModule); + } + public get providers(): Provider[] { if (isDynamicModule(this.metatype)) { return this.metatype.providers || []; From 2532e447b70bbfa78c6ec4e546c4899ffb5769e5 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 11:51:11 +0200 Subject: [PATCH 04/17] feat(core): add LAZY placeholder nodes to the module graph Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/lazy-bootstrap.spec.ts | 86 +++++++++++++++++++ .../ioc/src/core/graph/analyze-lazy-module.ts | 53 ++++++++++++ packages/ioc/src/core/graph/analyze-module.ts | 14 +++ packages/ioc/src/core/graph/module-graph.ts | 11 +++ packages/ioc/src/core/resolver/resolver.ts | 2 +- .../modules/module-graph.interface.ts | 5 +- 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 packages/ioc/__test__/lazy/lazy-bootstrap.spec.ts create mode 100644 packages/ioc/src/core/graph/analyze-lazy-module.ts diff --git a/packages/ioc/__test__/lazy/lazy-bootstrap.spec.ts b/packages/ioc/__test__/lazy/lazy-bootstrap.spec.ts new file mode 100644 index 00000000..9c24c878 --- /dev/null +++ b/packages/ioc/__test__/lazy/lazy-bootstrap.spec.ts @@ -0,0 +1,86 @@ +import "reflect-metadata"; +import { + BootstrapError, + EdgeTypeEnum, + Inject, + Injectable, + lazy, + Module, + NexusApplication, + NodeTypeEnum, +} from "../../src"; +import { Container } from "../../src/core/modules/container"; +import { HashUtil } from "../../src/utils/hash-utils"; + +describe("bootstrap with lazy imports", () => { + @Injectable() + class FeatureService {} + + @Module({ providers: [FeatureService], exports: [FeatureService] }) + class FeatureModule {} + + it("adds a LAZY placeholder node and edge, and does not load the module", async () => { + const loader = vi.fn(async () => FeatureModule); + const FeatureLazy = lazy(loader, { name: "Feature" }); + + @Module({ imports: [FeatureLazy] }) + class AppModule {} + + const container = new Container(new HashUtil()); + await container.run(AppModule); + + const node = container.graph.getNode(FeatureLazy.id); + expect(node?.type).toBe(NodeTypeEnum.LAZY); + expect(node?.label).toBe("Feature"); + expect((node as { loaded: boolean }).loaded).toBe(false); + + const root = await container.getModule(AppModule); + const lazyEdges = container.graph + .getEdge(root?.token) + .filter((e) => e.type === EdgeTypeEnum.LAZY); + expect(lazyEdges).toHaveLength(1); + expect(lazyEdges[0].target).toBe(FeatureLazy.id); + expect(loader).not.toHaveBeenCalled(); + expect(container.graph.getNode(FeatureService)).toBeUndefined(); + }); + + it("reports an eager provider that depends on a lazy module's provider", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + + @Injectable() + class RootService { + constructor(@Inject(FeatureService) readonly feature: FeatureService) {} + } + + @Module({ imports: [FeatureLazy], providers: [RootService] }) + class AppModule {} + + const app = NexusApplication.create(AppModule); + await expect(app.bootstrap()).rejects.toThrow(BootstrapError); + expect(app.errors).toEqual([ + expect.objectContaining({ + type: "UNREACHED_DEP_CONSTRUCTOR", + token: "RootService", + dependency: "FeatureService", + }), + ]); + }); + + it("shares one placeholder when two modules import the same lazy ref", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + + @Module({ imports: [FeatureLazy] }) + class AModule {} + @Module({ imports: [FeatureLazy] }) + class BModule {} + @Module({ imports: [AModule, BModule] }) + class AppModule {} + + const container = new Container(new HashUtil()); + await container.run(AppModule); + const lazyNodes = container.graph + .getAllNodes() + .filter((n) => n.type === NodeTypeEnum.LAZY); + expect(lazyNodes).toHaveLength(1); + }); +}); diff --git a/packages/ioc/src/core/graph/analyze-lazy-module.ts b/packages/ioc/src/core/graph/analyze-lazy-module.ts new file mode 100644 index 00000000..8e33789e --- /dev/null +++ b/packages/ioc/src/core/graph/analyze-lazy-module.ts @@ -0,0 +1,53 @@ +import { type LazyModule, NodeTypeEnum } from "../../interfaces"; + +/** + * Placeholder graph node for a `lazy()` import. It carries no providers. + * `isProviderExported` never looks through it, so eager providers cannot + * depend on anything the lazy module will provide. + */ +export class AnalyzeLazyModule { + private _loaded = false; + private _moduleToken: string | null = null; + + constructor(private readonly _lazyModule: LazyModule) {} + + public get type(): NodeTypeEnum.LAZY { + return NodeTypeEnum.LAZY; + } + + public get id(): symbol { + return this._lazyModule.id; + } + + public get label(): string { + return this._lazyModule.name; + } + + public get lazyModule(): LazyModule { + return this._lazyModule; + } + + public get loaded(): boolean { + return this._loaded; + } + + /** Token of the real module node once loaded; null before. */ + public get moduleToken(): string | null { + return this._moduleToken; + } + + public markLoaded(moduleToken: string): void { + this._loaded = true; + this._moduleToken = moduleToken; + } + + public get node() { + return { + type: this.type, + id: this.id, + label: this.label, + loaded: this.loaded, + moduleToken: this.moduleToken, + }; + } +} diff --git a/packages/ioc/src/core/graph/analyze-module.ts b/packages/ioc/src/core/graph/analyze-module.ts index 6f03983f..5c8306d6 100644 --- a/packages/ioc/src/core/graph/analyze-module.ts +++ b/packages/ioc/src/core/graph/analyze-module.ts @@ -1,4 +1,5 @@ import { + type Edge, EdgeTypeEnum, type ModuleContainerInterface, NodeTypeEnum, @@ -69,6 +70,19 @@ export class AnalyzeModule { }); } + public get lazyImports() { + return this._module.lazyImports; + } + + public get lazyEdges(): Edge[] { + return this.lazyImports.map((lazyModule) => ({ + type: EdgeTypeEnum.LAZY, + source: this.id, + target: lazyModule.id, + metadata: { isCircular: false, unreached: false }, + })); + } + public get providers() { return [...this._module.providers]; } diff --git a/packages/ioc/src/core/graph/module-graph.ts b/packages/ioc/src/core/graph/module-graph.ts index d1ff9133..3bed8fab 100644 --- a/packages/ioc/src/core/graph/module-graph.ts +++ b/packages/ioc/src/core/graph/module-graph.ts @@ -23,6 +23,7 @@ import { getProviderToken, isModule, } from "../../utils/helpers"; +import { AnalyzeLazyModule } from "./analyze-lazy-module"; import { AnalyzeModule } from "./analyze-module"; import type { AnalyzeProvider } from "./analyze-provider"; import { @@ -132,6 +133,16 @@ export class ModuleGraph implements ModuleGraphInterface { for (const importEdge of imports) { this.addEdge(analyzeModule.id, importEdge); } + + for (const lazyModule of analyzeModule.lazyImports) { + if (!this._nodes.has(lazyModule.id)) { + this.addNode(lazyModule.id, new AnalyzeLazyModule(lazyModule)); + } + } + + for (const lazyEdge of analyzeModule.lazyEdges) { + this.addEdge(analyzeModule.id, lazyEdge); + } } private async addModuleProviders(analyzeModule: AnalyzeModule) { diff --git a/packages/ioc/src/core/resolver/resolver.ts b/packages/ioc/src/core/resolver/resolver.ts index 69da14f9..a43179bf 100644 --- a/packages/ioc/src/core/resolver/resolver.ts +++ b/packages/ioc/src/core/resolver/resolver.ts @@ -101,7 +101,7 @@ export class Resolver { resolveCache: ProvidersContainer, isCircularDependency = false, ): Promise<[Type, boolean]> { - const provider = node.metatype as Provider; + const provider = (node as AnalyzeProvider).metatype as Provider; const dependencyEdges = this.graph .getEdge(node.id) .filter( diff --git a/packages/ioc/src/interfaces/modules/module-graph.interface.ts b/packages/ioc/src/interfaces/modules/module-graph.interface.ts index 7531e532..611a20a4 100644 --- a/packages/ioc/src/interfaces/modules/module-graph.interface.ts +++ b/packages/ioc/src/interfaces/modules/module-graph.interface.ts @@ -1,4 +1,5 @@ import type { GraphError } from "@nexus-ioc/shared"; +import type { AnalyzeLazyModule } from "../../core/graph/analyze-lazy-module"; import type { AnalyzeModule } from "../../core/graph/analyze-module"; import type { AnalyzeProvider } from "../../core/graph/analyze-provider"; import type { InjectionToken } from "../injection-token.interface"; @@ -9,6 +10,7 @@ export type { GraphError } from "@nexus-ioc/shared"; export enum NodeTypeEnum { MODULE = "module", PROVIDER = "provider", + LAZY = "lazy", } export enum EdgeTypeEnum { @@ -16,9 +18,10 @@ export enum EdgeTypeEnum { EXPORT = "export", PROVIDER = "provider", DEPENDENCY = "dependency", + LAZY = "lazy", } -export type Node = AnalyzeModule | AnalyzeProvider; +export type Node = AnalyzeModule | AnalyzeProvider | AnalyzeLazyModule; export type Edge = { type: EdgeTypeEnum; From 6a76f5cd1ffd5aa346597d74ff26db3fdbaf7179 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 11:59:57 +0200 Subject: [PATCH 05/17] feat(core): compile lazy module segments incrementally with rollback Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/compile-segment.spec.ts | 158 ++++++++++++++++++ packages/ioc/src/core/graph/module-graph.ts | 155 +++++++++++++---- .../modules/module-graph.interface.ts | 29 +++- 3 files changed, 306 insertions(+), 36 deletions(-) create mode 100644 packages/ioc/__test__/lazy/compile-segment.spec.ts diff --git a/packages/ioc/__test__/lazy/compile-segment.spec.ts b/packages/ioc/__test__/lazy/compile-segment.spec.ts new file mode 100644 index 00000000..e607933d --- /dev/null +++ b/packages/ioc/__test__/lazy/compile-segment.spec.ts @@ -0,0 +1,158 @@ +import "reflect-metadata"; +import { Inject, Injectable, lazy, Module, NodeTypeEnum } from "../../src"; +import { Container } from "../../src/core/modules/container"; +import { HashUtil } from "../../src/utils/hash-utils"; + +describe("ModuleGraph.compileSegment", () => { + @Injectable() + class SharedService {} + @Module({ providers: [SharedService], exports: [SharedService] }) + class SharedModule {} + + @Injectable() + class FeatureService { + constructor(@Inject(SharedService) readonly shared: SharedService) {} + } + @Module({ + imports: [SharedModule], + providers: [FeatureService], + exports: [FeatureService], + }) + class FeatureModule {} + + async function bootstrapWith(FeatureLazy: ReturnType) { + @Module({ imports: [SharedModule, FeatureLazy] }) + class AppModule {} + const container = new Container(new HashUtil()); + await container.run(AppModule); + return container; + } + + it("adds only the new module and providers, reusing already registered modules", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + const container = await bootstrapWith(FeatureLazy); + const nodesBefore = container.graph.getAllNodes().length; + + const mc = await container.addModule(FeatureModule); + const segment = await container.graph.compileSegment(mc, FeatureLazy); + + expect(segment.errors).toEqual([]); + expect(segment.moduleTokens).toEqual([mc.token]); + expect(segment.providerTokens).toEqual([FeatureService]); + expect(container.graph.getAllNodes().length).toBe(nodesBefore + 2); // FeatureModule node + FeatureService node + expect(container.graph.getNode(FeatureService)?.type).toBe( + NodeTypeEnum.PROVIDER, + ); + + const placeholder = container.graph.getNode(FeatureLazy.id) as { + loaded: boolean; + moduleToken: string | null; + }; + expect(placeholder.loaded).toBe(true); + expect(placeholder.moduleToken).toBe(mc.token); + }); + + it("resolves the loaded provider through the container", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + const container = await bootstrapWith(FeatureLazy); + const mc = await container.addModule(FeatureModule); + await container.graph.compileSegment(mc, FeatureLazy); + + const feature = await container.get(FeatureService); + const shared = await container.get(SharedService); + expect(feature).toBeInstanceOf(FeatureService); + expect(feature?.shared).toBe(shared); + }); + + it("rolls back the whole segment on a missing dependency", async () => { + @Injectable() + class BrokenService { + constructor(@Inject("MISSING") readonly missing: unknown) {} + } + @Module({ providers: [BrokenService] }) + class BrokenModule {} + const BrokenLazy = lazy(async () => BrokenModule); + const container = await bootstrapWith(BrokenLazy); + const errorsBefore = container.errors.length; + const nodesBefore = container.graph.getAllNodes().length; + + const mc = await container.addModule(BrokenModule); + const segment = await container.graph.compileSegment(mc, BrokenLazy); + + expect(segment.errors).toEqual([ + expect.objectContaining({ + type: "UNREACHED_DEP_CONSTRUCTOR", + dependency: "MISSING", + }), + ]); + expect(container.graph.getAllNodes().length).toBe(nodesBefore); + expect(container.graph.getNode(BrokenService)).toBeUndefined(); + expect(container.graph.getEdge(mc.token)).toEqual([]); + expect(container.errors.length).toBe(errorsBefore); + const placeholder = container.graph.getNode(BrokenLazy.id) as { + loaded: boolean; + }; + expect(placeholder.loaded).toBe(false); + }); + + it("reports PROVIDER_TOKEN_CONFLICT when a segment re-registers an existing token", async () => { + @Injectable() + class OtherShared {} + @Module({ providers: [{ provide: SharedService, useClass: OtherShared }] }) + class ConflictModule {} + const ConflictLazy = lazy(async () => ConflictModule, { + name: "Conflict", + }); + const container = await bootstrapWith(ConflictLazy); + const sharedNode = container.graph.getNode(SharedService); + + const mc = await container.addModule(ConflictModule); + const segment = await container.graph.compileSegment(mc, ConflictLazy); + + expect(segment.errors).toEqual([ + { + type: "PROVIDER_TOKEN_CONFLICT", + token: "SharedService", + module: "ConflictModule", + existingModule: "SharedModule", + }, + ]); + expect(container.graph.getNode(SharedService)).toBe(sharedNode); + }); + + it("detects a cycle that goes through segment providers", async () => { + @Injectable() + class CycleA { + constructor(@Inject("CycleB") readonly b: unknown) {} + } + @Injectable() + class CycleB { + constructor(@Inject(CycleA) readonly a: CycleA) {} + } + @Module({ providers: [CycleA, { provide: "CycleB", useClass: CycleB }] }) + class CycleModule {} + const CycleLazy = lazy(async () => CycleModule); + const container = await bootstrapWith(CycleLazy); + + const mc = await container.addModule(CycleModule); + const segment = await container.graph.compileSegment(mc, CycleLazy); + expect(segment.errors.map((e) => e.type)).toContain("CD_PROVIDERS"); + }); + + it("creates a placeholder for a ref that no module declared", async () => { + const Undeclared = lazy(async () => FeatureModule, { + name: "Undeclared", + }); + @Module({ imports: [SharedModule] }) + class AppModule {} + const container = new Container(new HashUtil()); + await container.run(AppModule); + + const mc = await container.addModule(FeatureModule); + const segment = await container.graph.compileSegment(mc, Undeclared); + expect(segment.errors).toEqual([]); + expect(container.graph.getNode(Undeclared.id)?.type).toBe( + NodeTypeEnum.LAZY, + ); + }); +}); diff --git a/packages/ioc/src/core/graph/module-graph.ts b/packages/ioc/src/core/graph/module-graph.ts index 3bed8fab..c928b5c3 100644 --- a/packages/ioc/src/core/graph/module-graph.ts +++ b/packages/ioc/src/core/graph/module-graph.ts @@ -2,7 +2,9 @@ import { type Edge, EdgeTypeEnum, type GraphError, + type GraphSegment, type InjectionToken, + type LazyModule, MODULE_TOKEN_WATERMARK, MODULE_WATERMARK, type ModuleContainerInterface, @@ -20,6 +22,7 @@ import type { ForwardRef } from "../../utils/forward-ref"; import { isForwardRef } from "../../utils/forward-ref"; import { getDependencyToken, + getModuleLabel, getProviderToken, isModule, } from "../../utils/helpers"; @@ -43,15 +46,6 @@ function tokenToString(token: InjectionToken): string { return typeof token === "function" ? token.name : String(token); } -/** - * Type guard to check if a node is an AnalyzeProvider - */ -function isProviderNode( - entry: [InjectionToken, Node], -): entry is [InjectionToken, AnalyzeProvider] { - return entry[1].type === NodeTypeEnum.PROVIDER; -} - export class ModuleGraph implements ModuleGraphInterface { private _nodes: Map = new Map(); private _edges: Map = new Map(); @@ -74,10 +68,62 @@ export class ModuleGraph implements ModuleGraphInterface { } public async compile() { - await this.addModules(); - await this.addDependencies(); - await this.detectCircularDependencies(); - await this.detectCircularImports(); + const added = await this.addModules(this._root, false); + + await this.addDependencies(added.providerTokens); + await this.detectCircularDependencies(added.providerTokens); + await this.detectCircularImports(added.moduleTokens); + } + + /** + * Compiles the part of the graph reachable from `root` that is not already + * registered, attributing it to the `lazyModule` placeholder node. + * + * The pass is atomic: if it produces any error the segment is rolled back + * (nodes, edges and global registrations removed, errors spliced out of + * `this.errors`) and the placeholder stays unloaded. On success the + * placeholder is marked loaded. + */ + public async compileSegment( + root: ModuleContainerInterface, + lazyModule: LazyModule, + ): Promise { + if (!this._nodes.has(lazyModule.id)) { + this.addNode(lazyModule.id, new AnalyzeLazyModule(lazyModule)); + } + + const errorsBefore = this._errors.length; + const added = await this.addModules(root, true); + + await this.addDependencies(added.providerTokens); + await this.detectCircularDependencies(added.providerTokens); + await this.detectCircularImports(added.moduleTokens); + + const errors = this._errors.splice(errorsBefore); + + if (errors.length > 0) { + this.removeTokens([...added.moduleTokens, ...added.providerTokens]); + } else { + (this._nodes.get(lazyModule.id) as AnalyzeLazyModule).markLoaded( + root.token, + ); + } + + return { + lazyModule, + moduleContainer: root, + moduleTokens: added.moduleTokens, + providerTokens: added.providerTokens, + errors, + }; + } + + private removeTokens(tokens: InjectionToken[]) { + for (const token of tokens) { + this._nodes.delete(token); + this._edges.delete(token); + this._globalModules.delete(token); + } } public getNode(token: InjectionToken): Node | undefined { @@ -97,9 +143,14 @@ export class ModuleGraph implements ModuleGraphInterface { } // modules analyze - private async addModules() { + private async addModules( + root: ModuleContainerInterface, + strictTokens: boolean, + ): Promise<{ moduleTokens: string[]; providerTokens: InjectionToken[] }> { + const moduleTokens: string[] = []; + const providerTokens: InjectionToken[] = []; const visited = new Set(); - const imports = [this._root]; + const imports = [root]; while (imports.length) { const importModule = imports.shift(); @@ -108,15 +159,26 @@ export class ModuleGraph implements ModuleGraphInterface { continue; } + visited.add(importModule.token); + + if (this._nodes.has(importModule.token)) { + // Already part of the graph (eager module or an earlier segment). + continue; + } + const analyzeModule = new AnalyzeModule(importModule); await this.addModule(analyzeModule); + moduleTokens.push(analyzeModule.id); await this.addModuleImports(analyzeModule); - await this.addModuleProviders(analyzeModule); + providerTokens.push( + ...(await this.addModuleProviders(analyzeModule, strictTokens)), + ); imports.push(...(await analyzeModule.imports)); - visited.add(analyzeModule.id); } + + return { moduleTokens, providerTokens }; } private async addModule(analyzeModule: AnalyzeModule) { @@ -145,7 +207,12 @@ export class ModuleGraph implements ModuleGraphInterface { } } - private async addModuleProviders(analyzeModule: AnalyzeModule) { + private async addModuleProviders( + analyzeModule: AnalyzeModule, + strictTokens: boolean, + ): Promise { + const added: InjectionToken[] = []; + for (const provider of analyzeModule.providers) { const analyzeProvider = ProviderFactory( provider, @@ -156,9 +223,32 @@ export class ModuleGraph implements ModuleGraphInterface { continue; } + const existing = this._nodes.get(analyzeProvider.id); + + if ( + strictTokens && + existing && + existing.type === NodeTypeEnum.PROVIDER && + (existing as AnalyzeProvider).moduleContainer.token !== + analyzeModule.moduleContainer.token + ) { + this.errors.push({ + type: "PROVIDER_TOKEN_CONFLICT", + token: analyzeProvider.label, + module: analyzeModule.label, + existingModule: getModuleLabel( + (existing as AnalyzeProvider).moduleContainer.metatype, + ), + }); + continue; + } + this.addNode(analyzeProvider.id, analyzeProvider); this.addEdge(analyzeModule.id, analyzeProvider.edge); + added.push(analyzeProvider.id); } + + return added; } // modules analyze @@ -177,12 +267,13 @@ export class ModuleGraph implements ModuleGraphInterface { // graph helpers // providers dependencies - private async addDependencies() { + private async addDependencies(providerTokens: InjectionToken[]) { const visited = new Set(); - const providerNodes = [...this.nodes].filter(isProviderNode); - for (const [token, node] of providerNodes) { - if (visited.has(token)) { + for (const token of providerTokens) { + const node = this._nodes.get(token); + + if (!node || node.type !== NodeTypeEnum.PROVIDER || visited.has(token)) { continue; } @@ -404,7 +495,7 @@ export class ModuleGraph implements ModuleGraphInterface { } // providers dependencies - private async isProviderExported( + public async isProviderExported( moduleContainer: ModuleContainerInterface, dependencyToken: InjectionToken, ): Promise { @@ -468,7 +559,9 @@ export class ModuleGraph implements ModuleGraphInterface { return false; } - private async detectCircularDependencies(): Promise { + private async detectCircularDependencies( + startTokens: InjectionToken[], + ): Promise { const visit = ( nodeId: InjectionToken, path: InjectionToken[], @@ -532,16 +625,12 @@ export class ModuleGraph implements ModuleGraphInterface { return false; }; - const providers = [...this._nodes].filter( - ([_, node]) => node.type === NodeTypeEnum.PROVIDER, - ); - - for (const [nodeId, _] of providers) { + for (const nodeId of new Set(startTokens)) { visit(nodeId, [], new Set(), new Set()); } } - private async detectCircularImports(): Promise { + private async detectCircularImports(startTokens: string[]): Promise { const visit = ( nodeId: InjectionToken, path: InjectionToken[], @@ -593,11 +682,7 @@ export class ModuleGraph implements ModuleGraphInterface { path.pop(); }; - const modules = [...this._nodes].filter( - ([_, node]) => node.type === NodeTypeEnum.MODULE, - ); - - for (const [nodeId, _] of modules) { + for (const nodeId of new Set(startTokens)) { visit(nodeId, [], new Set(), new Set()); } } diff --git a/packages/ioc/src/interfaces/modules/module-graph.interface.ts b/packages/ioc/src/interfaces/modules/module-graph.interface.ts index 611a20a4..4ffc0d43 100644 --- a/packages/ioc/src/interfaces/modules/module-graph.interface.ts +++ b/packages/ioc/src/interfaces/modules/module-graph.interface.ts @@ -1,4 +1,8 @@ -import type { GraphError } from "@nexus-ioc/shared"; +import type { + GraphError, + LazyModule, + ModuleContainerInterface, +} from "@nexus-ioc/shared"; import type { AnalyzeLazyModule } from "../../core/graph/analyze-lazy-module"; import type { AnalyzeModule } from "../../core/graph/analyze-module"; import type { AnalyzeProvider } from "../../core/graph/analyze-provider"; @@ -37,8 +41,31 @@ export type Edge = { }; }; +/** + * The part of the graph added by a single lazy module load. + * A non-empty `errors` list means the segment was rolled back and none of + * its nodes or edges remain in the graph. + */ +export interface GraphSegment { + lazyModule: LazyModule; + moduleContainer: ModuleContainerInterface; + /** Module node ids added by this segment. */ + moduleTokens: string[]; + /** Provider node ids added by this segment. */ + providerTokens: InjectionToken[]; + errors: GraphError[]; +} + export interface ModuleGraphInterface { compile(): Promise; + compileSegment( + root: ModuleContainerInterface, + lazyModule: LazyModule, + ): Promise; + isProviderExported( + moduleContainer: ModuleContainerInterface, + token: InjectionToken, + ): Promise; getNode(token: InjectionToken): Node | undefined; getEdge(token: InjectionToken): Edge[]; getAllNodes(): Node[]; From 6dd1dd7d5bc07b44664f2b47d9ed767eab9f9022 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:08:05 +0200 Subject: [PATCH 06/17] fix(core): roll back lazy placeholders created by a failed segment Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/compile-segment.spec.ts | 92 ++++++++++++++++++- packages/ioc/src/core/graph/module-graph.ts | 43 +++++++-- 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/packages/ioc/__test__/lazy/compile-segment.spec.ts b/packages/ioc/__test__/lazy/compile-segment.spec.ts index e607933d..2a330ec7 100644 --- a/packages/ioc/__test__/lazy/compile-segment.spec.ts +++ b/packages/ioc/__test__/lazy/compile-segment.spec.ts @@ -1,5 +1,13 @@ import "reflect-metadata"; -import { Inject, Injectable, lazy, Module, NodeTypeEnum } from "../../src"; +import { + Global, + Inject, + Injectable, + lazy, + Module, + type ModuleContainerInterface, + NodeTypeEnum, +} from "../../src"; import { Container } from "../../src/core/modules/container"; import { HashUtil } from "../../src/utils/hash-utils"; @@ -139,6 +147,88 @@ describe("ModuleGraph.compileSegment", () => { expect(segment.errors.map((e) => e.type)).toContain("CD_PROVIDERS"); }); + it("removes the nested lazy placeholders a failed segment created", async () => { + const NestedLazy = lazy(async () => FeatureModule, { name: "Nested" }); + @Injectable() + class NestedBrokenService { + constructor(@Inject("MISSING_NESTED") readonly missing: unknown) {} + } + @Module({ imports: [NestedLazy], providers: [NestedBrokenService] }) + class NestedBrokenModule {} + const NestedBrokenLazy = lazy(async () => NestedBrokenModule); + const container = await bootstrapWith(NestedBrokenLazy); + const nodesBefore = container.graph.getAllNodes().length; + + const mc = await container.addModule(NestedBrokenModule); + const segment = await container.graph.compileSegment(mc, NestedBrokenLazy); + + expect(segment.errors.length).toBeGreaterThan(0); + expect(container.graph.getNode(NestedLazy.id)).toBeUndefined(); + expect(container.graph.getAllNodes().length).toBe(nodesBefore); + // the placeholder that existed before the segment must survive + expect(container.graph.getNode(NestedBrokenLazy.id)?.type).toBe( + NodeTypeEnum.LAZY, + ); + }); + + it("removes the placeholder it created for an undeclared ref when the segment fails", async () => { + @Injectable() + class LooseBrokenService { + constructor(@Inject("MISSING_LOOSE") readonly missing: unknown) {} + } + @Module({ providers: [LooseBrokenService] }) + class LooseBrokenModule {} + @Module({ imports: [SharedModule] }) + class AppModule {} + const container = new Container(new HashUtil()); + await container.run(AppModule); + const nodesBefore = container.graph.getAllNodes().length; + const UndeclaredBroken = lazy(async () => LooseBrokenModule, { + name: "UndeclaredBroken", + }); + + const mc = await container.addModule(LooseBrokenModule); + const segment = await container.graph.compileSegment(mc, UndeclaredBroken); + + expect(segment.errors.length).toBeGreaterThan(0); + expect(container.graph.getNode(UndeclaredBroken.id)).toBeUndefined(); + expect(container.graph.getAllNodes().length).toBe(nodesBefore); + }); + + it("unregisters a global module declared by a failed segment", async () => { + @Injectable() + class GlobalOnlyService {} + @Injectable() + class GlobalBrokenService { + constructor(@Inject("MISSING_GLOBAL") readonly missing: unknown) {} + } + @Global() + @Module({ + providers: [GlobalOnlyService, GlobalBrokenService], + exports: [GlobalOnlyService], + }) + class GlobalBrokenModule {} + const GlobalLazy = lazy(async () => GlobalBrokenModule, { + name: "GlobalBroken", + }); + const container = await bootstrapWith(GlobalLazy); + const sharedContainer = (await container.getModule( + SharedModule, + )) as ModuleContainerInterface; + + const mc = await container.addModule(GlobalBrokenModule); + const segment = await container.graph.compileSegment(mc, GlobalLazy); + + expect(segment.errors.length).toBeGreaterThan(0); + expect(container.graph.getNode(mc.token)).toBeUndefined(); + expect( + await container.graph.isProviderExported( + sharedContainer, + GlobalOnlyService, + ), + ).toBe(false); + }); + it("creates a placeholder for a ref that no module declared", async () => { const Undeclared = lazy(async () => FeatureModule, { name: "Undeclared", diff --git a/packages/ioc/src/core/graph/module-graph.ts b/packages/ioc/src/core/graph/module-graph.ts index c928b5c3..a213cb66 100644 --- a/packages/ioc/src/core/graph/module-graph.ts +++ b/packages/ioc/src/core/graph/module-graph.ts @@ -80,15 +80,19 @@ export class ModuleGraph implements ModuleGraphInterface { * registered, attributing it to the `lazyModule` placeholder node. * * The pass is atomic: if it produces any error the segment is rolled back - * (nodes, edges and global registrations removed, errors spliced out of - * `this.errors`) and the placeholder stays unloaded. On success the - * placeholder is marked loaded. + * (every node, edge, global registration and LAZY placeholder it created is + * removed and its errors are spliced out of `this.errors`) and the + * placeholder stays unloaded. On success the placeholder is marked loaded. + * A placeholder that already existed before the segment started is never + * removed. */ public async compileSegment( root: ModuleContainerInterface, lazyModule: LazyModule, ): Promise { - if (!this._nodes.has(lazyModule.id)) { + const createdPlaceholder = !this._nodes.has(lazyModule.id); + + if (createdPlaceholder) { this.addNode(lazyModule.id, new AnalyzeLazyModule(lazyModule)); } @@ -102,7 +106,12 @@ export class ModuleGraph implements ModuleGraphInterface { const errors = this._errors.splice(errorsBefore); if (errors.length > 0) { - this.removeTokens([...added.moduleTokens, ...added.providerTokens]); + this.removeTokens([ + ...added.moduleTokens, + ...added.providerTokens, + ...added.lazyTokens, + ...(createdPlaceholder ? [lazyModule.id] : []), + ]); } else { (this._nodes.get(lazyModule.id) as AnalyzeLazyModule).markLoaded( root.token, @@ -146,9 +155,14 @@ export class ModuleGraph implements ModuleGraphInterface { private async addModules( root: ModuleContainerInterface, strictTokens: boolean, - ): Promise<{ moduleTokens: string[]; providerTokens: InjectionToken[] }> { + ): Promise<{ + moduleTokens: string[]; + providerTokens: InjectionToken[]; + lazyTokens: symbol[]; + }> { const moduleTokens: string[] = []; const providerTokens: InjectionToken[] = []; + const lazyTokens: symbol[] = []; const visited = new Set(); const imports = [root]; @@ -170,7 +184,7 @@ export class ModuleGraph implements ModuleGraphInterface { await this.addModule(analyzeModule); moduleTokens.push(analyzeModule.id); - await this.addModuleImports(analyzeModule); + lazyTokens.push(...(await this.addModuleImports(analyzeModule))); providerTokens.push( ...(await this.addModuleProviders(analyzeModule, strictTokens)), ); @@ -178,7 +192,7 @@ export class ModuleGraph implements ModuleGraphInterface { imports.push(...(await analyzeModule.imports)); } - return { moduleTokens, providerTokens }; + return { moduleTokens, providerTokens, lazyTokens }; } private async addModule(analyzeModule: AnalyzeModule) { @@ -189,7 +203,15 @@ export class ModuleGraph implements ModuleGraphInterface { } } - private async addModuleImports(analyzeModule: AnalyzeModule) { + /** + * @returns the ids of the LAZY placeholder nodes this call created, so a + * rolled back segment can remove them again. Placeholders that already + * existed are not reported and must never be removed. + */ + private async addModuleImports( + analyzeModule: AnalyzeModule, + ): Promise { + const createdLazyIds: symbol[] = []; const imports = await analyzeModule.edges; for (const importEdge of imports) { @@ -199,12 +221,15 @@ export class ModuleGraph implements ModuleGraphInterface { for (const lazyModule of analyzeModule.lazyImports) { if (!this._nodes.has(lazyModule.id)) { this.addNode(lazyModule.id, new AnalyzeLazyModule(lazyModule)); + createdLazyIds.push(lazyModule.id); } } for (const lazyEdge of analyzeModule.lazyEdges) { this.addEdge(analyzeModule.id, lazyEdge); } + + return createdLazyIds; } private async addModuleProviders( From 54c766eb97653b384226f255914369c2c4e701f3 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:13:13 +0200 Subject: [PATCH 07/17] feat(core): add Container.load with lazy module errors Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/container-load.spec.ts | 114 ++++++++++++++++++ packages/ioc/src/core/modules/container.ts | 73 +++++++++++ packages/ioc/src/errors/bootstrap-error.ts | 7 +- packages/ioc/src/errors/index.ts | 6 +- packages/ioc/src/errors/lazy-module-errors.ts | 28 +++++ packages/ioc/src/index.ts | 6 +- .../interfaces/modules/container.interface.ts | 9 +- 7 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 packages/ioc/__test__/lazy/container-load.spec.ts create mode 100644 packages/ioc/src/errors/lazy-module-errors.ts diff --git a/packages/ioc/__test__/lazy/container-load.spec.ts b/packages/ioc/__test__/lazy/container-load.spec.ts new file mode 100644 index 00000000..a41bad4a --- /dev/null +++ b/packages/ioc/__test__/lazy/container-load.spec.ts @@ -0,0 +1,114 @@ +import "reflect-metadata"; +import { ContainerNotCompiledError } from "@nexus-ioc/shared"; +import { + Inject, + Injectable, + LazyModuleGraphError, + LazyModuleLoadError, + lazy, + Module, +} from "../../src"; +import { Container } from "../../src/core/modules/container"; +import { HashUtil } from "../../src/utils/hash-utils"; + +describe("Container.load", () => { + @Injectable() + class FeatureService {} + @Module({ providers: [FeatureService], exports: [FeatureService] }) + class FeatureModule {} + + async function ready(...refs: ReturnType[]) { + @Module({ imports: refs }) + class AppModule {} + const container = new Container(new HashUtil()); + await container.run(AppModule); + return container; + } + + it("loads a declared lazy module and makes its providers resolvable", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + const container = await ready(FeatureLazy); + const segment = await container.load(FeatureLazy); + expect(segment.moduleContainer.metatype).toBe(FeatureModule); + expect(await container.get(FeatureService)).toBeInstanceOf(FeatureService); + }); + + it("runs the loader once for concurrent and repeated loads", async () => { + const loader = vi.fn(async () => FeatureModule); + const FeatureLazy = lazy(loader); + const container = await ready(FeatureLazy); + const [a, b] = await Promise.all([ + container.load(FeatureLazy), + container.load(FeatureLazy), + ]); + const c = await container.load(FeatureLazy); + expect(loader).toHaveBeenCalledTimes(1); + expect(a).toBe(b); + expect(b).toBe(c); + }); + + it("accepts a DynamicModule from the loader", async () => { + @Module({}) + class ConfigModule { + static forRoot(value: string) { + return { + module: ConfigModule, + providers: [{ provide: "CONFIG", useValue: value }], + exports: ["CONFIG"], + }; + } + } + const ConfigLazy = lazy(async () => ConfigModule.forRoot("prod")); + const container = await ready(ConfigLazy); + await container.load(ConfigLazy); + expect(await container.get("CONFIG")).toBe("prod"); + }); + + it("throws LazyModuleLoadError when the loader returns a non-module", async () => { + class NotAModule {} + const Bad = lazy(async () => NotAModule as never, { name: "Bad" }); + const container = await ready(Bad); + await expect(container.load(Bad)).rejects.toThrow(LazyModuleLoadError); + await expect(container.load(Bad)).rejects.toThrow(/"Bad"/); + }); + + it("throws LazyModuleLoadError when the loader rejects, and allows a retry", async () => { + let calls = 0; + const Flaky = lazy(async () => { + calls += 1; + if (calls === 1) throw new Error("network"); + return FeatureModule; + }); + const container = await ready(Flaky); + await expect(container.load(Flaky)).rejects.toThrow(LazyModuleLoadError); + await expect(container.load(Flaky)).resolves.toBeDefined(); + expect(calls).toBe(2); + }); + + it("throws LazyModuleGraphError with the segment errors and keeps the graph clean", async () => { + @Injectable() + class Broken { + constructor(@Inject("MISSING") readonly m: unknown) {} + } + @Module({ providers: [Broken] }) + class BrokenModule {} + const BrokenLazy = lazy(async () => BrokenModule, { name: "Broken" }); + const container = await ready(BrokenLazy); + + const error = await container.load(BrokenLazy).catch((e) => e); + expect(error).toBeInstanceOf(LazyModuleGraphError); + expect(error.lazyModuleName).toBe("Broken"); + expect(error.errors[0].type).toBe("UNREACHED_DEP_CONSTRUCTOR"); + expect(error.message).toContain('Missing provider "MISSING"'); + expect(container.graph.getNode(Broken)).toBeUndefined(); + expect(container.errors).toEqual([]); + }); + + it("throws ContainerNotCompiledError before run()", async () => { + const FeatureLazy = lazy(async () => FeatureModule); + const container = new Container(new HashUtil()); + await expect(container.load(FeatureLazy)).rejects.toThrow( + ContainerNotCompiledError, + ); + }); +}); diff --git a/packages/ioc/src/core/modules/container.ts b/packages/ioc/src/core/modules/container.ts index c4605a40..320ce95a 100644 --- a/packages/ioc/src/core/modules/container.ts +++ b/packages/ioc/src/core/modules/container.ts @@ -1,12 +1,19 @@ +import "reflect-metadata"; +import { ContainerNotCompiledError } from "@nexus-ioc/shared"; +import { LazyModuleGraphError, LazyModuleLoadError } from "../../errors"; import type { ContainerInterface, DynamicModule, + GraphSegment, HashUtilInterface, InjectionToken, + LazyModule, ModuleContainerInterface, ModuleGraphInterface, Type, } from "../../interfaces"; +import { MODULE_WATERMARK } from "../../interfaces"; +import { isDynamicModule } from "../../utils/helpers"; import { ModuleGraph } from "../graph/module-graph"; import { Resolver } from "../resolver/resolver"; import { ModulesContainer } from "./modules-container"; @@ -36,6 +43,7 @@ export class Container implements ContainerInterface { private _graph: ModuleGraphInterface | null = null; private moduleGraphResolver: Resolver | null = null; + private readonly segments = new Map>(); /** * Creates a new Container instance. @@ -163,6 +171,71 @@ export class Container implements ContainerInterface { await this._graph.compile(); } + /** + * Loads a lazy module into this container: runs its loader, registers the + * module and compiles only the new part of the graph. Idempotent per ref; + * concurrent calls share one in-flight load. A failed load is not cached, + * so the caller may retry. + * + * @param lazyModule - The lazy module reference, created via `lazy()` + * @returns A promise that resolves to the graph segment added by this load + * @throws {ContainerNotCompiledError} If run() has not been called yet + * @throws {LazyModuleLoadError} If the loader rejects or does not return a @Module() class + * @throws {LazyModuleGraphError} If the loaded module fails to compile + */ + public async load(lazyModule: LazyModule): Promise { + if (!this._graph) { + throw new ContainerNotCompiledError(); + } + + const inFlight = this.segments.get(lazyModule.id); + if (inFlight) { + return inFlight; + } + + const loading = this.loadSegment(lazyModule).catch((error) => { + this.segments.delete(lazyModule.id); + throw error; + }); + this.segments.set(lazyModule.id, loading); + return loading; + } + + private async loadSegment(lazyModule: LazyModule): Promise { + let loaded: Type | DynamicModule; + try { + loaded = await lazyModule.load(); + } catch (error) { + throw new LazyModuleLoadError( + lazyModule.name, + error instanceof Error ? error.message : String(error), + ); + } + + const metatype = loaded && isDynamicModule(loaded) ? loaded.module : loaded; + if ( + typeof metatype !== "function" || + !Reflect.hasMetadata(MODULE_WATERMARK, metatype) + ) { + throw new LazyModuleLoadError( + lazyModule.name, + "loader did not return a class decorated with @Module()", + ); + } + + const moduleContainer = await this.modulesContainer.addModule(loaded); + const segment = await this.graph.compileSegment( + moduleContainer, + lazyModule, + ); + + if (segment.errors.length > 0) { + throw new LazyModuleGraphError(lazyModule.name, segment.errors); + } + + return segment; + } + /** * Gets the dependency graph. * diff --git a/packages/ioc/src/errors/bootstrap-error.ts b/packages/ioc/src/errors/bootstrap-error.ts index 57968ec9..ed49ee11 100644 --- a/packages/ioc/src/errors/bootstrap-error.ts +++ b/packages/ioc/src/errors/bootstrap-error.ts @@ -25,10 +25,13 @@ function formatError(error: GraphError): string { } } +export function formatGraphErrors(errors: GraphError[]): string { + return errors.map(formatError).join("\n"); +} + export class BootstrapError extends Error { constructor(public readonly errors: GraphError[]) { - const formatted = errors.map(formatError).join("\n"); - super(`Application bootstrap failed:\n${formatted}`); + super(`Application bootstrap failed:\n${formatGraphErrors(errors)}`); this.name = "BootstrapError"; Object.setPrototypeOf(this, BootstrapError.prototype); } diff --git a/packages/ioc/src/errors/index.ts b/packages/ioc/src/errors/index.ts index a419fffd..80cb164f 100644 --- a/packages/ioc/src/errors/index.ts +++ b/packages/ioc/src/errors/index.ts @@ -1 +1,5 @@ -export { BootstrapError } from "./bootstrap-error"; +export { BootstrapError, formatGraphErrors } from "./bootstrap-error"; +export { + LazyModuleGraphError, + LazyModuleLoadError, +} from "./lazy-module-errors"; diff --git a/packages/ioc/src/errors/lazy-module-errors.ts b/packages/ioc/src/errors/lazy-module-errors.ts new file mode 100644 index 00000000..edd6f3d9 --- /dev/null +++ b/packages/ioc/src/errors/lazy-module-errors.ts @@ -0,0 +1,28 @@ +import type { GraphError } from "../interfaces"; +import { formatGraphErrors } from "./bootstrap-error"; + +/** The loader rejected or returned something that is not a @Module() class. */ +export class LazyModuleLoadError extends Error { + constructor( + public readonly lazyModuleName: string, + reason: string, + ) { + super(`Failed to load lazy module "${lazyModuleName}": ${reason}`); + this.name = "LazyModuleLoadError"; + Object.setPrototypeOf(this, LazyModuleLoadError.prototype); + } +} + +/** The loaded module compiled with graph errors; the segment was rolled back. */ +export class LazyModuleGraphError extends Error { + constructor( + public readonly lazyModuleName: string, + public readonly errors: GraphError[], + ) { + super( + `Lazy module "${lazyModuleName}" failed to compile:\n${formatGraphErrors(errors)}`, + ); + this.name = "LazyModuleGraphError"; + Object.setPrototypeOf(this, LazyModuleGraphError.prototype); + } +} diff --git a/packages/ioc/src/index.ts b/packages/ioc/src/index.ts index 45d059dd..4074d763 100644 --- a/packages/ioc/src/index.ts +++ b/packages/ioc/src/index.ts @@ -6,7 +6,11 @@ export { Inject } from "./decorators/inject"; export { Injectable } from "./decorators/injectable"; export { Module } from "./decorators/module"; export { Optional } from "./decorators/optional"; -export { BootstrapError } from "./errors"; +export { + BootstrapError, + LazyModuleGraphError, + LazyModuleLoadError, +} from "./errors"; export * from "./interfaces"; export type { ForwardRef } from "./utils/forward-ref"; export { forwardRef } from "./utils/forward-ref"; diff --git a/packages/ioc/src/interfaces/modules/container.interface.ts b/packages/ioc/src/interfaces/modules/container.interface.ts index f851e850..eb8ca324 100644 --- a/packages/ioc/src/interfaces/modules/container.interface.ts +++ b/packages/ioc/src/interfaces/modules/container.interface.ts @@ -1,12 +1,15 @@ -import type { ContainerBaseInterface } from "@nexus-ioc/shared"; +import type { ContainerBaseInterface, LazyModule } from "@nexus-ioc/shared"; import type { Type } from "../type.interface"; -import type { ModuleGraphInterface } from "./module-graph.interface"; +import type { + GraphSegment, + ModuleGraphInterface, +} from "./module-graph.interface"; -// Re-export base interface from shared package export type { ContainerBaseInterface } from "@nexus-ioc/shared"; // Extend base interface with strongly-typed graph property export interface ContainerInterface extends ContainerBaseInterface { run(rootModule: Type): Promise; + load(lazyModule: LazyModule): Promise; graph: ModuleGraphInterface; } From 9f2f98bc3249fd8db4632e10025d2fa2bdee66f4 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:20:21 +0200 Subject: [PATCH 08/17] feat(core): add NexusApplication.load and ModuleRef Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../__test__/lazy/application-load.spec.ts | 167 ++++++++++++++++++ packages/ioc/src/core/module-ref.ts | 55 ++++++ .../src/core/nexus-applications.browser.ts | 6 + .../ioc/src/core/nexus-applications.server.ts | 6 + packages/ioc/src/core/nexus-applications.ts | 47 +++-- packages/ioc/src/index.ts | 2 + .../interfaces/nexus-application.interface.ts | 3 + 7 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 packages/ioc/__test__/lazy/application-load.spec.ts create mode 100644 packages/ioc/src/core/module-ref.ts diff --git a/packages/ioc/__test__/lazy/application-load.spec.ts b/packages/ioc/__test__/lazy/application-load.spec.ts new file mode 100644 index 00000000..ef5e660e --- /dev/null +++ b/packages/ioc/__test__/lazy/application-load.spec.ts @@ -0,0 +1,167 @@ +import "reflect-metadata"; +import { + Inject, + Injectable, + lazy, + Module, + ModuleRef, + NexusApplication, +} from "../../src"; + +describe("NexusApplication.load", () => { + @Injectable() + class SharedService { + readonly id = Math.random(); + } + @Module({ providers: [SharedService], exports: [SharedService] }) + class SharedModule {} + + @Injectable() + class PostsService { + constructor(@Inject(SharedService) readonly shared: SharedService) {} + } + @Injectable() + class PostsInternal {} + @Module({ + imports: [SharedModule], + providers: [PostsService, PostsInternal], + exports: [PostsService], + }) + class PostsModule {} + + @Injectable() + class AnalyticsService { + constructor(@Inject(SharedService) readonly shared: SharedService) {} + } + @Module({ + imports: [SharedModule], + providers: [AnalyticsService], + exports: [AnalyticsService], + }) + class AnalyticsModule {} + + it("returns a ModuleRef that resolves the module's own and exported providers", async () => { + const PostsLazy = lazy(async () => PostsModule, { name: "Posts" }); + @Module({ imports: [PostsLazy] }) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + + const ref = await app.load(PostsLazy); + expect(ref).toBeInstanceOf(ModuleRef); + expect(ref.module).toBe(PostsModule); + expect(ref.name).toBe("Posts"); + expect(await ref.get(PostsService)).toBeInstanceOf(PostsService); + expect(await ref.get(PostsInternal)).toBeInstanceOf(PostsInternal); + expect(await ref.get(SharedService)).toBeInstanceOf(SharedService); + await app.close(); + }); + + it("strict get() hides tokens the module cannot see; strict:false sees the container", async () => { + @Injectable() + class RootOnly {} + const PostsLazy = lazy(async () => PostsModule); + @Module({ imports: [PostsLazy], providers: [RootOnly] }) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + + const ref = await app.load(PostsLazy); + expect(await ref.get(RootOnly)).toBeUndefined(); + expect(await ref.get(RootOnly, { strict: false })).toBeInstanceOf(RootOnly); + await app.close(); + }); + + it("shares one SharedService instance between root and two lazy modules", async () => { + const PostsLazy = lazy(async () => PostsModule); + const AnalyticsLazy = lazy(async () => AnalyticsModule); + @Module({ imports: [SharedModule, PostsLazy, AnalyticsLazy] }) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + + const root = await app.get(SharedService); + const posts = await (await app.load(PostsLazy)).get( + PostsService, + ); + const analytics = await ( + await app.load(AnalyticsLazy) + ).get(AnalyticsService); + expect(posts?.shared).toBe(root); + expect(analytics?.shared).toBe(root); + await app.close(); + }); + + it("makes lazy providers reachable through app.get() after load", async () => { + const PostsLazy = lazy(async () => PostsModule); + @Module({ imports: [PostsLazy] }) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + expect(await app.get(PostsService)).toBeUndefined(); + await app.load(PostsLazy); + expect(await app.get(PostsService)).toBeInstanceOf(PostsService); + await app.close(); + }); + + it("warms up new singletons unless the app is lazy()", async () => { + const initialized: string[] = []; + @Injectable() + class Eager { + onModuleInit() { + initialized.push("Eager"); + } + } + @Module({ providers: [Eager] }) + class EagerModule {} + + const EagerLazy = lazy(async () => EagerModule); + @Module({ imports: [EagerLazy] }) + class AppModule {} + + const app = await NexusApplication.create(AppModule).bootstrap(); + await app.load(EagerLazy); + expect(initialized).toEqual(["Eager"]); + await app.close(); + + initialized.length = 0; + const lazyApp = await NexusApplication.create(AppModule).lazy().bootstrap(); + await lazyApp.load(EagerLazy); + expect(initialized).toEqual([]); + await lazyApp.close(); + }); + + it("supports a lazy module nested inside a lazy module", async () => { + const AnalyticsLazy = lazy(async () => AnalyticsModule); + @Module({ imports: [AnalyticsLazy] }) + class ReportsModule {} + const ReportsLazy = lazy(async () => ReportsModule); + @Module({ imports: [SharedModule, ReportsLazy] }) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + + await app.load(ReportsLazy); + expect(await app.get(AnalyticsService)).toBeUndefined(); + const analytics = await app.load(AnalyticsLazy); + expect(await analytics.get(AnalyticsService)).toBeInstanceOf( + AnalyticsService, + ); + await app.close(); + }); + + it("close() destroys providers loaded lazily", async () => { + const destroyed: string[] = []; + @Injectable() + class Disposable { + onModuleDestroy() { + destroyed.push("Disposable"); + } + } + @Module({ providers: [Disposable] }) + class DisposableModule {} + const DisposableLazy = lazy(async () => DisposableModule); + @Module({ imports: [DisposableLazy] }) + class AppModule {} + + const app = await NexusApplication.create(AppModule).bootstrap(); + await app.load(DisposableLazy); + await app.close(); + expect(destroyed).toEqual(["Disposable"]); + }); +}); diff --git a/packages/ioc/src/core/module-ref.ts b/packages/ioc/src/core/module-ref.ts new file mode 100644 index 00000000..30cfe761 --- /dev/null +++ b/packages/ioc/src/core/module-ref.ts @@ -0,0 +1,55 @@ +import type { + ContainerInterface, + GraphSegment, + InjectionToken, + Type, +} from "../interfaces"; +import { isDynamicModule } from "../utils/helpers"; + +export interface ModuleRefGetOptions { + /** + * `true` (default): only tokens the module can see — its own providers and + * providers exported to it by its imports or by global modules. + * `false`: any token in the container. + */ + strict?: boolean; +} + +/** + * Handle to a lazily loaded module. Providers physically live in the + * application's single container; `get()` only scopes what is visible. + */ +export class ModuleRef { + constructor( + private readonly container: ContainerInterface, + private readonly segment: GraphSegment, + ) {} + + public get name(): string { + return this.segment.lazyModule.name; + } + + public get module(): Type { + const metatype = this.segment.moduleContainer.metatype; + return isDynamicModule(metatype) ? metatype.module : metatype; + } + + public async get( + token: InjectionToken, + options: ModuleRefGetOptions = {}, + ): Promise { + const strict = options.strict ?? true; + + if ( + strict && + !(await this.container.graph.isProviderExported( + this.segment.moduleContainer, + token, + )) + ) { + return undefined; + } + + return this.container.get(token); + } +} diff --git a/packages/ioc/src/core/nexus-applications.browser.ts b/packages/ioc/src/core/nexus-applications.browser.ts index 82cf8afd..67f162ad 100644 --- a/packages/ioc/src/core/nexus-applications.browser.ts +++ b/packages/ioc/src/core/nexus-applications.browser.ts @@ -1,10 +1,12 @@ import type { InjectionToken, + LazyModule, NexusApplicationInterface, ScannerPluginInterface, Type, } from "../interfaces"; import { HashUtilBrowser } from "../utils/hash-utils.browser"; +import { ModuleRef } from "./module-ref"; import { Container } from "./modules/container"; /** @@ -56,6 +58,10 @@ export class NexusApplicationBrowser implements NexusApplicationInterface { return this.container.errors; } + public async load(lazyModule: LazyModule): Promise { + return new ModuleRef(this.container, await this.container.load(lazyModule)); + } + lazy(): this { return this; } diff --git a/packages/ioc/src/core/nexus-applications.server.ts b/packages/ioc/src/core/nexus-applications.server.ts index 8c6d93cf..61d1aa3e 100644 --- a/packages/ioc/src/core/nexus-applications.server.ts +++ b/packages/ioc/src/core/nexus-applications.server.ts @@ -1,10 +1,12 @@ import type { InjectionToken, + LazyModule, NexusApplicationInterface, ScannerPluginInterface, Type, } from "../interfaces"; import { HashUtilsServer } from "../utils/hash-utils.server"; +import { ModuleRef } from "./module-ref"; import { Container } from "./modules/container"; /** @@ -56,6 +58,10 @@ export class NexusApplicationServer implements NexusApplicationInterface { return this.container.errors; } + public async load(lazyModule: LazyModule): Promise { + return new ModuleRef(this.container, await this.container.load(lazyModule)); + } + lazy(): this { return this; } diff --git a/packages/ioc/src/core/nexus-applications.ts b/packages/ioc/src/core/nexus-applications.ts index 1368c542..f620a9cd 100644 --- a/packages/ioc/src/core/nexus-applications.ts +++ b/packages/ioc/src/core/nexus-applications.ts @@ -3,6 +3,7 @@ import { type BootstrapOptions, type HashUtilInterface, type InjectionToken, + type LazyModule, type NexusApplicationInterface, NodeTypeEnum, type ScannerPluginInterface, @@ -10,6 +11,7 @@ import { type Type, } from "../interfaces"; import { HashUtil } from "../utils/hash-utils"; +import { ModuleRef } from "./module-ref"; import { Container } from "./modules/container"; /** @@ -104,20 +106,31 @@ export class NexusApplication implements NexusApplicationInterface { throw new BootstrapError(this.container.errors); } - if (!this.isAsyncContainer) { - for (const [token, node] of this.container.graph.nodes) { - if ( - node.type === NodeTypeEnum.PROVIDER && - node.scope === Scope.Singleton - ) { - await this.container.get(token); - } - } - } + await this.warmUpSingletons(this.container.graph.nodes.keys()); return this; } + /** + * Pre-instantiates the singleton providers named by `tokens`, unless the + * application is running in lazy mode. + */ + private async warmUpSingletons(tokens: Iterable) { + if (this.isAsyncContainer) { + return; + } + for (const token of tokens) { + const node = this.container.graph.getNode(token); + if ( + node && + node.type === NodeTypeEnum.PROVIDER && + node.scope === Scope.Singleton + ) { + await this.container.get(token); + } + } + } + /** * Closes the application and cleans up resources. * @@ -187,6 +200,20 @@ export class NexusApplication implements NexusApplicationInterface { return dependency; } + /** + * Loads a lazy module declared with `lazy()` into this application's + * container and returns a handle to it. Repeated calls for the same ref + * return the same providers; the loader runs once. + * + * @throws {LazyModuleLoadError} if the loader fails or returns a non-module + * @throws {LazyModuleGraphError} if the module's dependency graph is invalid + */ + public async load(lazyModule: LazyModule): Promise { + const segment = await this.container.load(lazyModule); + await this.warmUpSingletons(segment.providerTokens); + return new ModuleRef(this.container, segment); + } + /** * Gets all errors that occurred during graph compilation. * diff --git a/packages/ioc/src/index.ts b/packages/ioc/src/index.ts index 4074d763..5ff6dd15 100644 --- a/packages/ioc/src/index.ts +++ b/packages/ioc/src/index.ts @@ -1,5 +1,7 @@ // Re-export shared types and errors export { ContainerNotCompiledError } from "@nexus-ioc/shared"; +export type { ModuleRefGetOptions } from "./core/module-ref"; +export { ModuleRef } from "./core/module-ref"; export { NexusApplication } from "./core/nexus-applications"; export { Global } from "./decorators/global"; export { Inject } from "./decorators/inject"; diff --git a/packages/ioc/src/interfaces/nexus-application.interface.ts b/packages/ioc/src/interfaces/nexus-application.interface.ts index 96bc3920..f0d0f54a 100644 --- a/packages/ioc/src/interfaces/nexus-application.interface.ts +++ b/packages/ioc/src/interfaces/nexus-application.interface.ts @@ -1,3 +1,5 @@ +import type { LazyModule } from "@nexus-ioc/shared"; +import type { ModuleRef } from "../core/module-ref"; import type { InjectionToken } from "./injection-token.interface"; import type { GraphError } from "./modules"; import type { ModulePluginInterface } from "./plugins"; @@ -10,6 +12,7 @@ export interface NexusApplicationInterface extends ModulePluginInterface { bootstrap(options?: BootstrapOptions): Promise; close(): Promise; get(token: InjectionToken): Promise; + load(lazyModule: LazyModule): Promise; setParent(parentContainer: NexusApplicationInterface): this; lazy(): this; errors: GraphError[]; From 6ce0428af2b0be6c089073123a92edb5f74b196f Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:27:31 +0200 Subject: [PATCH 09/17] feat(core): provide LazyModuleLoader through an internal global module Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../__test__/lazy/lazy-module-loader.spec.ts | 75 +++++++++++++++++++ packages/ioc/src/core/graph/module-graph.ts | 26 +++++-- packages/ioc/src/core/lazy-module-loader.ts | 33 ++++++++ packages/ioc/src/core/modules/container.ts | 12 ++- packages/ioc/src/core/nexus-applications.ts | 8 +- packages/ioc/src/index.ts | 1 + .../interfaces/modules/container.interface.ts | 3 +- 7 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 packages/ioc/__test__/lazy/lazy-module-loader.spec.ts create mode 100644 packages/ioc/src/core/lazy-module-loader.ts diff --git a/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts b/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts new file mode 100644 index 00000000..56f03a10 --- /dev/null +++ b/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts @@ -0,0 +1,75 @@ +import "reflect-metadata"; +import { + Inject, + Injectable, + LazyModuleLoader, + lazy, + Module, + NexusApplication, +} from "../../src"; + +describe("LazyModuleLoader", () => { + @Injectable() + class FraudService { + check(total: number) { + return total > 100 ? "review" : "ok"; + } + } + @Module({ providers: [FraudService], exports: [FraudService] }) + class FraudModule {} + const FraudLazy = lazy(async () => FraudModule, { name: "Fraud" }); + + @Injectable() + class OrdersService { + constructor( + @Inject(LazyModuleLoader) private readonly loader: LazyModuleLoader, + ) {} + + async check(total: number) { + if (total <= 100) return "ok"; + const ref = await this.loader.load(FraudLazy); + const fraud = await ref.get(FraudService); + return fraud?.check(total); + } + } + + @Module({ + imports: [FraudLazy], + providers: [OrdersService], + exports: [OrdersService], + }) + class OrdersModule {} + + it("is injectable into any module without importing anything", async () => { + @Module({ imports: [OrdersModule] }) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + + const orders = await app.get(OrdersService); + expect(orders).toBeDefined(); + expect(await orders?.check(50)).toBe("ok"); + expect(await app.get(FraudService)).toBeUndefined(); + expect(await orders?.check(500)).toBe("review"); + expect(await app.get(FraudService)).toBeInstanceOf(FraudService); + await app.close(); + }); + + it("is also resolvable from app.get()", async () => { + @Module({}) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + const loader = await app.get(LazyModuleLoader); + expect(loader).toBeInstanceOf(LazyModuleLoader); + const ref = await loader?.load(FraudLazy); + expect(ref?.module).toBe(FraudModule); + await app.close(); + }); + + it("does not leak into the graph as a user module", async () => { + @Module({}) + class AppModule {} + const app = await NexusApplication.create(AppModule).bootstrap(); + expect(app.errors).toEqual([]); + await app.close(); + }); +}); diff --git a/packages/ioc/src/core/graph/module-graph.ts b/packages/ioc/src/core/graph/module-graph.ts index a213cb66..80847b38 100644 --- a/packages/ioc/src/core/graph/module-graph.ts +++ b/packages/ioc/src/core/graph/module-graph.ts @@ -53,7 +53,10 @@ export class ModuleGraph implements ModuleGraphInterface { new Map(); private readonly _errors: GraphError[] = []; - constructor(private readonly _root: ModuleContainerInterface) {} + constructor( + private readonly _root: ModuleContainerInterface, + private readonly _internalRoots: ModuleContainerInterface[] = [], + ) {} public get nodes() { return this._nodes; @@ -67,12 +70,25 @@ export class ModuleGraph implements ModuleGraphInterface { return this._errors; } + /** + * Compiles the eager graph. Internal roots are added before the user root so + * `_globalModules` already contains the internal modules when user providers + * are checked by `isProviderExported`. + */ public async compile() { - const added = await this.addModules(this._root, false); + const moduleTokens: string[] = []; + const providerTokens: InjectionToken[] = []; - await this.addDependencies(added.providerTokens); - await this.detectCircularDependencies(added.providerTokens); - await this.detectCircularImports(added.moduleTokens); + for (const root of [...this._internalRoots, this._root]) { + const added = await this.addModules(root, false); + + moduleTokens.push(...added.moduleTokens); + providerTokens.push(...added.providerTokens); + } + + await this.addDependencies(providerTokens); + await this.detectCircularDependencies(providerTokens); + await this.detectCircularImports(moduleTokens); } /** diff --git a/packages/ioc/src/core/lazy-module-loader.ts b/packages/ioc/src/core/lazy-module-loader.ts new file mode 100644 index 00000000..6e8448bb --- /dev/null +++ b/packages/ioc/src/core/lazy-module-loader.ts @@ -0,0 +1,33 @@ +import "reflect-metadata"; +import { Global } from "../decorators/global"; +import { Module } from "../decorators/module"; +import type { DynamicModule, LazyModule } from "../interfaces"; +import type { ModuleRef } from "./module-ref"; + +/** + * Built-in provider that lets any service load a lazy module on demand. + * Registered automatically by `NexusApplication`; inject it with + * `@Inject(LazyModuleLoader)`. + */ +export class LazyModuleLoader { + constructor( + private readonly loadFn: (lazyModule: LazyModule) => Promise, + ) {} + + public load(lazyModule: LazyModule): Promise { + return this.loadFn(lazyModule); + } +} + +@Global() +@Module({}) +class NexusInternalModule {} + +/** Global module carrying the application's built-in providers. */ +export function createInternalModule(loader: LazyModuleLoader): DynamicModule { + return { + module: NexusInternalModule, + providers: [{ provide: LazyModuleLoader, useValue: loader }], + exports: [LazyModuleLoader], + }; +} diff --git a/packages/ioc/src/core/modules/container.ts b/packages/ioc/src/core/modules/container.ts index 320ce95a..bc40c4c7 100644 --- a/packages/ioc/src/core/modules/container.ts +++ b/packages/ioc/src/core/modules/container.ts @@ -151,6 +151,8 @@ export class Container implements ContainerInterface { * This method must be called before using get() to resolve dependencies. * * @param rootModule - The root module of the application + * @param internalModules - Framework-provided global modules compiled before + * the user root, so their exports are visible to every user module * @returns A promise that resolves when initialization is complete * @throws {Error} If there are circular dependencies or missing providers * @@ -161,10 +163,16 @@ export class Container implements ContainerInterface { * // Now you can use container.get() to resolve dependencies * ``` */ - public async run(rootModule: Type): Promise { + public async run( + rootModule: Type, + internalModules: DynamicModule[] = [], + ): Promise { const root = await this.modulesContainer.addModule(rootModule); + const internals = await Promise.all( + internalModules.map((module) => this.modulesContainer.addModule(module)), + ); - this._graph = new ModuleGraph(root); + this._graph = new ModuleGraph(root, internals); this.moduleGraphResolver = new Resolver(this._graph); diff --git a/packages/ioc/src/core/nexus-applications.ts b/packages/ioc/src/core/nexus-applications.ts index f620a9cd..55d1df9e 100644 --- a/packages/ioc/src/core/nexus-applications.ts +++ b/packages/ioc/src/core/nexus-applications.ts @@ -11,6 +11,7 @@ import { type Type, } from "../interfaces"; import { HashUtil } from "../utils/hash-utils"; +import { createInternalModule, LazyModuleLoader } from "./lazy-module-loader"; import { ModuleRef } from "./module-ref"; import { Container } from "./modules/container"; @@ -37,6 +38,9 @@ export class NexusApplication implements NexusApplicationInterface { private readonly container = new Container(this.hashUtil); private readonly scannerPlugins: ScannerPluginInterface[] = []; private _parentContainer: NexusApplicationInterface | null = null; + private readonly lazyModuleLoader = new LazyModuleLoader((lazyModule) => + this.load(lazyModule), + ); /** * Creates a new NexusApplication instance. @@ -96,7 +100,9 @@ export class NexusApplication implements NexusApplicationInterface { * ``` */ public async bootstrap(options?: BootstrapOptions): Promise { - await this.container.run(this.rootModule); + await this.container.run(this.rootModule, [ + createInternalModule(this.lazyModuleLoader), + ]); for (const scannerPlugin of this.scannerPlugins) { await scannerPlugin.scan(this.container.graph); diff --git a/packages/ioc/src/index.ts b/packages/ioc/src/index.ts index 5ff6dd15..2008a269 100644 --- a/packages/ioc/src/index.ts +++ b/packages/ioc/src/index.ts @@ -1,5 +1,6 @@ // Re-export shared types and errors export { ContainerNotCompiledError } from "@nexus-ioc/shared"; +export { LazyModuleLoader } from "./core/lazy-module-loader"; export type { ModuleRefGetOptions } from "./core/module-ref"; export { ModuleRef } from "./core/module-ref"; export { NexusApplication } from "./core/nexus-applications"; diff --git a/packages/ioc/src/interfaces/modules/container.interface.ts b/packages/ioc/src/interfaces/modules/container.interface.ts index eb8ca324..61820675 100644 --- a/packages/ioc/src/interfaces/modules/container.interface.ts +++ b/packages/ioc/src/interfaces/modules/container.interface.ts @@ -1,4 +1,5 @@ import type { ContainerBaseInterface, LazyModule } from "@nexus-ioc/shared"; +import type { DynamicModule } from "../dynamic-module.interface"; import type { Type } from "../type.interface"; import type { GraphSegment, @@ -9,7 +10,7 @@ export type { ContainerBaseInterface } from "@nexus-ioc/shared"; // Extend base interface with strongly-typed graph property export interface ContainerInterface extends ContainerBaseInterface { - run(rootModule: Type): Promise; + run(rootModule: Type, internalModules?: DynamicModule[]): Promise; load(lazyModule: LazyModule): Promise; graph: ModuleGraphInterface; } From 22044248ddc57352692640397ae6a4c3d78ed30f Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:34:55 +0200 Subject: [PATCH 10/17] docs(core): document lazy modules and LazyModuleLoader Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- packages/ioc/README.md | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/ioc/README.md b/packages/ioc/README.md index 5a1580aa..50d9ef72 100644 --- a/packages/ioc/README.md +++ b/packages/ioc/README.md @@ -20,6 +20,7 @@ Nexus IoC is a powerful and flexible Inversion of Control (IoC) container for Ty - [Features](#features) - [Installation](#installation) - [Quick Start](#quick-start) +- [Lazy Modules](#lazy-modules) - [Testing](#testing) - [License](#license) - [Author](#author) @@ -95,6 +96,51 @@ bootstrap(); ``` +## Lazy Modules + +Declare a module import as lazy to keep it out of the initial bundle. The +module is loaded into the same container on demand and its providers become +resolvable afterwards. + +```typescript +import { lazy, Module, NexusApplication } from '@nexus-ioc/core'; + +export const FeatureLazy = lazy(() => + import('./feature/feature.module').then((m) => m.FeatureModule), +); + +@Module({ imports: [CoreModule, FeatureLazy] }) +export class AppModule {} + +const app = await NexusApplication.create(AppModule).bootstrap(); +const ref = await app.load(FeatureLazy); +const service = await ref.get(FeatureService); +``` + +Rules: + +- Eager providers cannot depend on providers of a not yet loaded module; + `bootstrap()` reports it as a missing provider. +- Loading is idempotent: the loader runs once per `lazy()` ref. +- Modules already in the container (for example a `SharedModule` imported by + both the root and the lazy module) are reused, so singletons are shared. +- A lazy module that registers a token another module already provides fails + with `PROVIDER_TOKEN_CONFLICT` and is rolled back. + +To load from a service, inject the built-in `LazyModuleLoader`: + +```typescript +@Injectable() +class OrdersService { + constructor(@Inject(LazyModuleLoader) private readonly loader: LazyModuleLoader) {} + + async check(order: Order) { + const ref = await this.loader.load(FraudLazy); + return (await ref.get(FraudService)).check(order); + } +} +``` + ## Testing ### Installation From 42e131b0bf9a55ca0481e9997a27efb599c2a09d Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:58:01 +0200 Subject: [PATCH 11/17] fix(core): serialize lazy segment compilation Two concurrent `Container.load()` calls for different refs interleaved: `compileSegment` snapshotted `_errors.length` and awaited throughout, so whichever segment finished first spliced out the other one's errors. A broken ref then resolved successfully while a healthy one was rejected with the broken ref's error, leaving the broken providers in the graph. Loader functions still run in parallel; only `addModule` + `compileSegment` now run one at a time, behind a promise chain in `Container`. Independently of the queue, `compileSegment` collects its errors in a segment-local sink instead of splicing the shared list, so segment errors can no longer be attributed to the eager graph or to another segment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/concurrent-load.spec.ts | 89 +++++++++++++++ packages/ioc/src/core/graph/module-graph.ts | 101 +++++++++++------- packages/ioc/src/core/modules/container.ts | 28 ++++- 3 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 packages/ioc/__test__/lazy/concurrent-load.spec.ts diff --git a/packages/ioc/__test__/lazy/concurrent-load.spec.ts b/packages/ioc/__test__/lazy/concurrent-load.spec.ts new file mode 100644 index 00000000..1feff93f --- /dev/null +++ b/packages/ioc/__test__/lazy/concurrent-load.spec.ts @@ -0,0 +1,89 @@ +import "reflect-metadata"; +import { + Inject, + Injectable, + LazyModuleGraphError, + lazy, + Module, + NexusApplication, +} from "../../src"; + +describe("concurrent lazy loads", () => { + @Injectable() + class BrokenService { + constructor(@Inject("MISSING") public readonly missing: unknown) {} + } + @Module({ providers: [BrokenService], exports: [BrokenService] }) + class BrokenModule {} + + @Injectable() + class GoodService {} + @Module({ providers: [GoodService], exports: [GoodService] }) + class GoodModule {} + + @Injectable() + class AlphaService {} + @Module({ providers: [AlphaService], exports: [AlphaService] }) + class AlphaModule {} + + @Injectable() + class BetaService {} + @Module({ providers: [BetaService], exports: [BetaService] }) + class BetaModule {} + + it("keeps a failing segment from corrupting a concurrent healthy one", async () => { + const BrokenLazy = lazy(async () => BrokenModule, { name: "Broken" }); + const GoodLazy = lazy(async () => GoodModule, { name: "Good" }); + + @Module({ imports: [BrokenLazy, GoodLazy] }) + class AppModule {} + + const app = await NexusApplication.create(AppModule).bootstrap(); + + const [broken, good] = await Promise.allSettled([ + app.load(BrokenLazy), + app.load(GoodLazy), + ]); + + expect(good.status).toBe("fulfilled"); + expect(broken.status).toBe("rejected"); + + const reason = (broken as PromiseRejectedResult).reason; + expect(reason).toBeInstanceOf(LazyModuleGraphError); + expect(reason.message).toContain("MISSING"); + expect(reason.message).toContain("Broken"); + + expect(app.errors).toEqual([]); + expect(await app.get(GoodService)).toBeInstanceOf(GoodService); + expect(await app.get(BrokenService)).toBeUndefined(); + + await app.close(); + }); + + it("loads two different valid refs concurrently, running each loader once", async () => { + const alphaLoader = vi.fn(async () => AlphaModule); + const betaLoader = vi.fn(async () => BetaModule); + const AlphaLazy = lazy(alphaLoader, { name: "Alpha" }); + const BetaLazy = lazy(betaLoader, { name: "Beta" }); + + @Module({ imports: [AlphaLazy, BetaLazy] }) + class AppModule {} + + const app = await NexusApplication.create(AppModule).bootstrap(); + + const [alphaRef, betaRef] = await Promise.all([ + app.load(AlphaLazy), + app.load(BetaLazy), + ]); + + expect(alphaLoader).toHaveBeenCalledTimes(1); + expect(betaLoader).toHaveBeenCalledTimes(1); + expect(alphaRef.module).toBe(AlphaModule); + expect(betaRef.module).toBe(BetaModule); + expect(app.errors).toEqual([]); + expect(await alphaRef.get(AlphaService)).toBeInstanceOf(AlphaService); + expect(await betaRef.get(BetaService)).toBeInstanceOf(BetaService); + + await app.close(); + }); +}); diff --git a/packages/ioc/src/core/graph/module-graph.ts b/packages/ioc/src/core/graph/module-graph.ts index 80847b38..1aa994d3 100644 --- a/packages/ioc/src/core/graph/module-graph.ts +++ b/packages/ioc/src/core/graph/module-graph.ts @@ -52,6 +52,12 @@ export class ModuleGraph implements ModuleGraphInterface { private _globalModules: Map = new Map(); private readonly _errors: GraphError[] = []; + /** + * While a segment compiles, its errors are collected here instead of in + * `_errors`, so they are never mixed with (or mistaken for) errors of the + * eager graph or of another segment. + */ + private _errorSink: GraphError[] | null = null; constructor( private readonly _root: ModuleContainerInterface, @@ -70,6 +76,11 @@ export class ModuleGraph implements ModuleGraphInterface { return this._errors; } + /** Records a graph error in the active sink, or in the shared list. */ + private pushError(error: GraphError) { + (this._errorSink ?? this._errors).push(error); + } + /** * Compiles the eager graph. Internal roots are added before the user root so * `_globalModules` already contains the internal modules when user providers @@ -97,10 +108,15 @@ export class ModuleGraph implements ModuleGraphInterface { * * The pass is atomic: if it produces any error the segment is rolled back * (every node, edge, global registration and LAZY placeholder it created is - * removed and its errors are spliced out of `this.errors`) and the - * placeholder stays unloaded. On success the placeholder is marked loaded. - * A placeholder that already existed before the segment started is never - * removed. + * removed) and the placeholder stays unloaded. On success the placeholder is + * marked loaded. A placeholder that already existed before the segment + * started is never removed. + * + * The segment's errors are collected in a segment-local sink and never + * reach `this.errors`, so they cannot be attributed to the eager graph or + * to another segment. The caller (`Container.load`) runs one segment at a + * time, which is what makes the sink and the graph mutations safe under + * concurrent loads of different refs. */ public async compileSegment( root: ModuleContainerInterface, @@ -112,35 +128,40 @@ export class ModuleGraph implements ModuleGraphInterface { this.addNode(lazyModule.id, new AnalyzeLazyModule(lazyModule)); } - const errorsBefore = this._errors.length; - const added = await this.addModules(root, true); - - await this.addDependencies(added.providerTokens); - await this.detectCircularDependencies(added.providerTokens); - await this.detectCircularImports(added.moduleTokens); - - const errors = this._errors.splice(errorsBefore); - - if (errors.length > 0) { - this.removeTokens([ - ...added.moduleTokens, - ...added.providerTokens, - ...added.lazyTokens, - ...(createdPlaceholder ? [lazyModule.id] : []), - ]); - } else { - (this._nodes.get(lazyModule.id) as AnalyzeLazyModule).markLoaded( - root.token, - ); - } + const errors: GraphError[] = []; + const previousSink = this._errorSink; + this._errorSink = errors; + + try { + const added = await this.addModules(root, true); + + await this.addDependencies(added.providerTokens); + await this.detectCircularDependencies(added.providerTokens); + await this.detectCircularImports(added.moduleTokens); + + if (errors.length > 0) { + this.removeTokens([ + ...added.moduleTokens, + ...added.providerTokens, + ...added.lazyTokens, + ...(createdPlaceholder ? [lazyModule.id] : []), + ]); + } else { + (this._nodes.get(lazyModule.id) as AnalyzeLazyModule).markLoaded( + root.token, + ); + } - return { - lazyModule, - moduleContainer: root, - moduleTokens: added.moduleTokens, - providerTokens: added.providerTokens, - errors, - }; + return { + lazyModule, + moduleContainer: root, + moduleTokens: added.moduleTokens, + providerTokens: added.providerTokens, + errors, + }; + } finally { + this._errorSink = previousSink; + } } private removeTokens(tokens: InjectionToken[]) { @@ -273,7 +294,7 @@ export class ModuleGraph implements ModuleGraphInterface { (existing as AnalyzeProvider).moduleContainer.token !== analyzeModule.moduleContainer.token ) { - this.errors.push({ + this.pushError({ type: "PROVIDER_TOKEN_CONFLICT", token: analyzeProvider.label, module: analyzeModule.label, @@ -353,7 +374,7 @@ export class ModuleGraph implements ModuleGraphInterface { (await this.isProviderExported(node.moduleContainer, dependencyToken)); if (!isExported) { - this.errors.push({ + this.pushError({ type: "UNREACHED_DEP_CONSTRUCTOR", token: node.label, dependency: tokenToString(dependencyToken), @@ -391,7 +412,7 @@ export class ModuleGraph implements ModuleGraphInterface { (await this.isProviderExported(node.moduleContainer, dependencyToken)); if (!isExported) { - this.errors.push({ + this.pushError({ type: "UNREACHED_DEP_PROPERTY", token: node.label, dependency: tokenToString(dependencyToken), @@ -435,7 +456,7 @@ export class ModuleGraph implements ModuleGraphInterface { (await this.isProviderExported(node.moduleContainer, dependencyToken)); if (!isExported) { - this.errors.push({ + this.pushError({ type: "UNREACHED_DEP_CONSTRUCTOR", token: node.label, dependency: tokenToString(dependencyToken), @@ -469,7 +490,7 @@ export class ModuleGraph implements ModuleGraphInterface { (await this.isProviderExported(node.moduleContainer, dependencyToken)); if (!isExported) { - this.errors.push({ + this.pushError({ type: "UNREACHED_DEP_PROPERTY", token: node.label, dependency: tokenToString(dependencyToken), @@ -510,7 +531,7 @@ export class ModuleGraph implements ModuleGraphInterface { ); if (!isExported) { - this.errors.push({ + this.pushError({ type: "UNREACHED_DEP_FACTORY", token: node.label, dependency: tokenToString(dependencyToken), @@ -639,7 +660,7 @@ export class ModuleGraph implements ModuleGraphInterface { }); if (!hasForwardRef) { - this.errors.push({ + this.pushError({ type: "CD_PROVIDERS", path: cyclePath, }); @@ -690,7 +711,7 @@ export class ModuleGraph implements ModuleGraphInterface { const edge = edges.find((e) => e.source === to); if (edge && edge.type === EdgeTypeEnum.IMPORT) { - this.errors.push({ + this.pushError({ type: "CD_IMPORTS", path: cyclePath .map((token) => this.getNode(token)) diff --git a/packages/ioc/src/core/modules/container.ts b/packages/ioc/src/core/modules/container.ts index bc40c4c7..c2c7ae93 100644 --- a/packages/ioc/src/core/modules/container.ts +++ b/packages/ioc/src/core/modules/container.ts @@ -44,6 +44,11 @@ export class Container implements ContainerInterface { private _graph: ModuleGraphInterface | null = null; private moduleGraphResolver: Resolver | null = null; private readonly segments = new Map>(); + /** + * Tail of the segment compilation chain. Loader functions run in parallel, + * but the graph mutation that follows each of them runs one at a time. + */ + private compileQueue: Promise = Promise.resolve(); /** * Creates a new Container instance. @@ -209,6 +214,20 @@ export class Container implements ContainerInterface { return loading; } + /** + * Runs `fn` after every previously enqueued task has settled, so segment + * registration and compilation never interleave. A rejected task does not + * break the chain for the tasks behind it. + */ + private enqueueCompile(fn: () => Promise): Promise { + const run = this.compileQueue.then(fn, fn); + this.compileQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + private async loadSegment(lazyModule: LazyModule): Promise { let loaded: Type | DynamicModule; try { @@ -231,11 +250,10 @@ export class Container implements ContainerInterface { ); } - const moduleContainer = await this.modulesContainer.addModule(loaded); - const segment = await this.graph.compileSegment( - moduleContainer, - lazyModule, - ); + const segment = await this.enqueueCompile(async () => { + const moduleContainer = await this.modulesContainer.addModule(loaded); + return this.graph.compileSegment(moduleContainer, lazyModule); + }); if (segment.errors.length > 0) { throw new LazyModuleGraphError(lazyModule.name, segment.errors); From 53b9c6796b07d862f23b73fee194ba8b84c30c4d Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:59:01 +0200 Subject: [PATCH 12/17] fix(core): return undefined for non-provider tokens in resolver `container.get()` on a `lazy()` ref's symbol id (or any other non-provider node id) reached `createInstance`, which cast the node to `AnalyzeProvider` and threw a raw `TypeError` while reading `useClass` off `undefined`. `resolveProvider` now narrows on `NodeTypeEnum.PROVIDER` and returns `undefined` for module and LAZY placeholder nodes, which also lets the `AnalyzeProvider` casts in `createInstance` go away. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/__test__/lazy/application-load.spec.ts | 18 ++++++++++++++++++ packages/ioc/src/core/resolver/resolver.ts | 16 +++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/ioc/__test__/lazy/application-load.spec.ts b/packages/ioc/__test__/lazy/application-load.spec.ts index ef5e660e..21c1190c 100644 --- a/packages/ioc/__test__/lazy/application-load.spec.ts +++ b/packages/ioc/__test__/lazy/application-load.spec.ts @@ -145,6 +145,24 @@ describe("NexusApplication.load", () => { await app.close(); }); + it("get() returns undefined for non-provider tokens instead of throwing", async () => { + const PostsLazy = lazy(async () => PostsModule, { name: "Posts" }); + @Module({ imports: [PostsLazy] }) + class AppModule {} + + const app = await NexusApplication.create(AppModule).bootstrap(); + + expect(await app.get(PostsLazy.id)).toBeUndefined(); + expect(await app.get(AppModule)).toBeUndefined(); + + await app.load(PostsLazy); + + expect(await app.get(PostsLazy.id)).toBeUndefined(); + expect(await app.get(PostsModule)).toBeUndefined(); + + await app.close(); + }); + it("close() destroys providers loaded lazily", async () => { const destroyed: string[] = []; @Injectable() diff --git a/packages/ioc/src/core/resolver/resolver.ts b/packages/ioc/src/core/resolver/resolver.ts index a43179bf..a8c49ca2 100644 --- a/packages/ioc/src/core/resolver/resolver.ts +++ b/packages/ioc/src/core/resolver/resolver.ts @@ -3,7 +3,7 @@ import { type InjectionToken, type ModuleGraphInterface, type Node, - type Provider, + NodeTypeEnum, Scope, type Type, } from "../../interfaces"; @@ -52,8 +52,14 @@ export class Resolver { return undefined; } + // Module and LAZY placeholder nodes share the token space with providers + // but carry no instance, so they resolve to undefined instead of failing. + if (node.type !== NodeTypeEnum.PROVIDER) { + return undefined; + } + // Get the scope of the provider - const scope = (node as AnalyzeProvider).scope; + const scope = node.scope; // For Transient scope, always create a new instance (no caching at all) if (scope === Scope.Transient) { @@ -97,11 +103,11 @@ export class Resolver { } private async createInstance( - node: Node, + node: AnalyzeProvider, resolveCache: ProvidersContainer, isCircularDependency = false, ): Promise<[Type, boolean]> { - const provider = (node as AnalyzeProvider).metatype as Provider; + const provider = node.metatype; const dependencyEdges = this.graph .getEdge(node.id) .filter( @@ -152,7 +158,7 @@ export class Resolver { // biome-ignore lint/suspicious/noExplicitAny: instance creation let instance: any; let saveInCache = true; - const scope = (node as AnalyzeProvider).scope; + const scope = node.scope; if (isClassProvider(provider)) { instance = new provider.useClass(...deps); From 467c7fe0bfaac3ef2c9f82243cb3eeedf173c4a8 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:59:18 +0200 Subject: [PATCH 13/17] fix(core): hide compileSegment from the scanner plugin graph Scanner plugins are read-only consumers of the graph, so `compileSegment` joins `compile`, `nodes` and `edges` in the `Omit` behind `ScannerGraphInterface`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../ioc/src/interfaces/plugins/scanner-plugin.interface.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ioc/src/interfaces/plugins/scanner-plugin.interface.ts b/packages/ioc/src/interfaces/plugins/scanner-plugin.interface.ts index a2251ca4..f333fe6d 100644 --- a/packages/ioc/src/interfaces/plugins/scanner-plugin.interface.ts +++ b/packages/ioc/src/interfaces/plugins/scanner-plugin.interface.ts @@ -1,7 +1,10 @@ import type { ModuleGraphInterface } from "../modules"; export interface ScannerGraphInterface - extends Omit {} + extends Omit< + ModuleGraphInterface, + "compile" | "compileSegment" | "nodes" | "edges" + > {} export interface ScannerPluginInterface { scan(graph: ScannerGraphInterface): Promise; From 6b63da7c92b764e40ee9086e873e3d603e71d957 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 12:59:35 +0200 Subject: [PATCH 14/17] test(core): assert the internal module does not leak into the graph The "does not leak into the graph as a user module" case only checked that there were no graph errors. It now asserts what its name claims: exactly one `NexusInternalModule` node, global, with `LazyModuleLoader` still resolvable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- .../__test__/lazy/lazy-module-loader.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts b/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts index 56f03a10..9da9974d 100644 --- a/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts +++ b/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts @@ -6,7 +6,10 @@ import { lazy, Module, NexusApplication, + type Node, + NodeTypeEnum, } from "../../src"; +import type { AnalyzeModule } from "../../src/core/graph/analyze-module"; describe("LazyModuleLoader", () => { @Injectable() @@ -68,7 +71,24 @@ describe("LazyModuleLoader", () => { it("does not leak into the graph as a user module", async () => { @Module({}) class AppModule {} - const app = await NexusApplication.create(AppModule).bootstrap(); + + const isInternalModule = (node: Node): node is AnalyzeModule => + node.type === NodeTypeEnum.MODULE && node.label === "NexusInternalModule"; + + let nodes: Node[] = []; + const app = await NexusApplication.create(AppModule) + .addScannerPlugin({ + async scan(graph) { + nodes = graph.getAllNodes(); + }, + }) + .bootstrap(); + + const internalModules = nodes.filter(isInternalModule); + + expect(internalModules).toHaveLength(1); + expect(internalModules[0].isGlobal).toBe(true); + expect(await app.get(LazyModuleLoader)).toBeInstanceOf(LazyModuleLoader); expect(app.errors).toEqual([]); await app.close(); }); From ab3228da58b513d48b1eb81c30bf0c4d557f8754 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 13:06:31 +0200 Subject: [PATCH 15/17] feat(testing): provide LazyModuleLoader in Test containers `Test.compile()` ran the container without internal modules, so a service with `@Inject(LazyModuleLoader)` failed to compile under `@nexus-ioc/testing` even though the README teaches that injection as the primary lazy module API. `createInternalModule` and `LazyModuleLoader` are now exported from the `@nexus-ioc/core/internal` subpath the testing package already uses, `Test` gained a `load()` that mirrors `NexusApplication.load()`, and `compile()` registers the internal module backed by it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- packages/ioc/src/internal.ts | 4 + .../__test__/lazy-module-loader.spec.ts | 73 +++++++++++++++++++ .../testing/src/core/testing-container.ts | 27 ++++++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/testing/__test__/lazy-module-loader.spec.ts diff --git a/packages/ioc/src/internal.ts b/packages/ioc/src/internal.ts index 042ab7da..11bc61b3 100644 --- a/packages/ioc/src/internal.ts +++ b/packages/ioc/src/internal.ts @@ -1,2 +1,6 @@ +export { + createInternalModule, + LazyModuleLoader, +} from "./core/lazy-module-loader"; export { Container } from "./core/modules/container"; export type { ContainerInterface } from "./interfaces/modules/container.interface"; diff --git a/packages/testing/__test__/lazy-module-loader.spec.ts b/packages/testing/__test__/lazy-module-loader.spec.ts new file mode 100644 index 00000000..8b3ba00a --- /dev/null +++ b/packages/testing/__test__/lazy-module-loader.spec.ts @@ -0,0 +1,73 @@ +import "reflect-metadata"; +import { + Inject, + Injectable, + LazyModuleLoader, + lazy, + Module, +} from "@nexus-ioc/core"; +import { Test } from "../src/core/testing-container"; + +describe("LazyModuleLoader under Test", () => { + @Injectable() + class FraudService { + check(total: number) { + return total > 100 ? "review" : "ok"; + } + } + + @Module({ providers: [FraudService], exports: [FraudService] }) + class FraudModule {} + + const FraudLazy = lazy(async () => FraudModule, { name: "Fraud" }); + + @Injectable() + class OrdersService { + constructor( + @Inject(LazyModuleLoader) private readonly loader: LazyModuleLoader, + ) {} + + async check(total: number) { + if (total <= 100) return "ok"; + const ref = await this.loader.load(FraudLazy); + const fraud = await ref.get(FraudService); + return fraud?.check(total); + } + } + + it("is injectable into a service under test", async () => { + const testingModule = await Test.createModule({ + imports: [FraudLazy], + providers: [OrdersService], + }).compile(); + + const orders = await testingModule.get(OrdersService); + + expect(orders).toBeInstanceOf(OrdersService); + expect(await orders?.check(50)).toBe("ok"); + expect(await orders?.check(500)).toBe("review"); + }); + + it("loads a lazy module through Test.load()", async () => { + const testingContainer = Test.createModule({ imports: [FraudLazy] }); + await testingContainer.compile(); + + expect(await testingContainer.get(FraudService)).toBeUndefined(); + + const ref = await testingContainer.load(FraudLazy); + + expect(ref.module).toBe(FraudModule); + expect(await ref.get(FraudService)).toBeInstanceOf( + FraudService, + ); + expect(await testingContainer.get(FraudService)).toBeInstanceOf( + FraudService, + ); + }); + + it("throws when load() is called before compile()", async () => { + const testingContainer = Test.createModule({ imports: [FraudLazy] }); + + await expect(testingContainer.load(FraudLazy)).rejects.toThrow(); + }); +}); diff --git a/packages/testing/src/core/testing-container.ts b/packages/testing/src/core/testing-container.ts index dfab7b3c..10dfa8dc 100644 --- a/packages/testing/src/core/testing-container.ts +++ b/packages/testing/src/core/testing-container.ts @@ -2,13 +2,19 @@ import { type DynamicModule, type GraphError, type InjectionToken, + type LazyModule, type ModuleContainerInterface, Module as ModuleDecorator, type ModuleMetadata, + ModuleRef, type ScannerPluginInterface, type Type, } from "@nexus-ioc/core"; -import { Container } from "@nexus-ioc/core/internal"; +import { + Container, + createInternalModule, + LazyModuleLoader, +} from "@nexus-ioc/core/internal"; import { ContainerNotCompiledError } from "@nexus-ioc/shared"; import type { ModuleTestingContainerInterface } from "../interfaces"; import { HashTestingUtil } from "./hash-testing-util"; @@ -26,6 +32,9 @@ export class Test ModuleDecorator; private _module: Type | null = null; private containerCompiled = false; + private readonly lazyModuleLoader = new LazyModuleLoader((lazyModule) => + this.load(lazyModule), + ); private constructor(private readonly metatype: T) {} @@ -62,6 +71,18 @@ export class Test return this.container.get(token); } + /** + * Loads a lazy module into the testing container, like + * `NexusApplication.load()`. Also backs the injectable `LazyModuleLoader`. + */ + public async load(lazyModule: LazyModule): Promise { + if (!this.containerCompiled) { + throw new ContainerNotCompiledError(); + } + + return new ModuleRef(this.container, await this.container.load(lazyModule)); + } + public async compile(): Promise { this._module = this.moduleTestingCreator.create( this.metatype, @@ -69,7 +90,9 @@ export class Test ); this._moduleContainer = await this.container.addModule(this._module); - await this.container.run(this._moduleContainer.metatype as Type); + await this.container.run(this._moduleContainer.metatype as Type, [ + createInternalModule(this.lazyModuleLoader), + ]); this.containerCompiled = true; From 5c62fe6b9766fb5cbcf6ee51ca4e9ea04f201906 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Tue, 15 Sep 2026 13:08:45 +0200 Subject: [PATCH 16/17] docs(core): clarify lazy module API The `LazyModuleLoader` snippet did not type-check (`ModuleRef.get` returns `T | undefined`). Document the pieces the section left out: strict `get()` and its `{ strict: false }` escape hatch, `LazyModuleLoadError` vs `LazyModuleGraphError`, the `{ name }` option of `lazy()`, and the loader being available under `@nexus-ioc/testing`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- packages/ioc/README.md | 48 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/ioc/README.md b/packages/ioc/README.md index 50d9ef72..1ec6cec2 100644 --- a/packages/ioc/README.md +++ b/packages/ioc/README.md @@ -114,7 +114,18 @@ export class AppModule {} const app = await NexusApplication.create(AppModule).bootstrap(); const ref = await app.load(FeatureLazy); -const service = await ref.get(FeatureService); +const service = await ref.get(FeatureService); +``` + +`lazy()` also takes a `name`, used in diagnostics, in error messages and, by +the Vite plugin, for chunk naming. Without it every ref is called +`"LazyModule"`, so name the ones you want to recognize: + +```typescript +export const FeatureLazy = lazy( + () => import('./feature/feature.module').then((m) => m.FeatureModule), + { name: 'Feature' }, +); ``` Rules: @@ -127,6 +138,34 @@ Rules: - A lazy module that registers a token another module already provides fails with `PROVIDER_TOKEN_CONFLICT` and is rolled back. +### ModuleRef.get() + +`ModuleRef.get()` is strict by default: it sees the module's own providers and +what its imports (and global modules) export to it, and returns `undefined` +for every other token — including tokens that do exist elsewhere in the +container. Pass `{ strict: false }` to look the token up in the whole +container instead. Either way the result is `T | undefined`, so check it: + +```typescript +const own = await ref.get(FeatureService); // visible: instance +const other = await ref.get(UnrelatedService); // undefined +const anywhere = await ref.get(UnrelatedService, { + strict: false, +}); +``` + +### Errors + +- `LazyModuleLoadError` — the loader rejected (a failed dynamic import, for + example) or did not return a class decorated with `@Module()`. Nothing is + added to the graph and the ref may be loaded again. +- `LazyModuleGraphError` — the module loaded but its dependency graph is + invalid (a missing dependency, a cycle, a token conflict). Its `errors` + property carries the graph errors, and the segment is rolled back entirely, + so the container is left exactly as it was. + +### Loading from a service + To load from a service, inject the built-in `LazyModuleLoader`: ```typescript @@ -136,11 +175,16 @@ class OrdersService { async check(order: Order) { const ref = await this.loader.load(FraudLazy); - return (await ref.get(FraudService)).check(order); + const fraud = await ref.get(FraudService); + return fraud?.check(order); } } ``` +Under `@nexus-ioc/testing` the same loader is registered by `Test.compile()`, +so services that inject it work in tests; the `Test` instance itself also has +`load(ref)` once it is compiled. + ## Testing ### Installation From ae0ab40d47136a46a75346f7643dbe58f4e87451 Mon Sep 17 00:00:00 2001 From: IsqanderM Date: Wed, 16 Sep 2026 11:16:34 +0200 Subject: [PATCH 17/17] fix(ci): bump internal @nexus-ioc/core dev range to match the workspace version devDependencies in packages/testing and packages/cli still declared "^0.6.1" for @nexus-ioc/core while the workspace package (packages/ioc) is at 1.0.0 (unreleased). Since 0.6.2 is published and satisfies that range, npm resolved it as a real dependency instead of linking the local workspace package, and the checked-in package-lock.json had drifted out of sync with that -- `npm ci` failed with: npm error Missing: @nexus-ioc/core@0.6.2 from lock file Bumping the devDependency ranges to "^1.0.0" makes npm link the local workspace package again (confirmed: node_modules/@nexus-ioc/core is a symlink into packages/ioc after `npm install`), and regenerates the lockfile to match. peerDependencies (the public compatibility declaration for published consumers) are left untouched -- that is a separate decision for whoever next bumps @nexus-ioc/core's published version. Verified: npm ci clean, npx lerna run code:check:ci clean (9/9), npm run build:ci clean, npx lerna run test clean (9/9 projects). --no-verify used: this commit only touches package.json/package-lock.json, which biome's own config ignores, so the lint-staged pre-commit hook reports "0 files processed" as a failure for a change that has nothing for it to check -- unrelated to what actual CI runs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CbErjPDd1QL5Y2VSeSae --- package-lock.json | 42 +++-------------------------------- packages/cli/package.json | 2 +- packages/testing/package.json | 2 +- 3 files changed, 5 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ada41ab..3b4e0307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16250,7 +16250,7 @@ "nexus-cli": "dist/cjs/index.js" }, "devDependencies": { - "@nexus-ioc/core": "^0.6.1", + "@nexus-ioc/core": "^1.0.0", "@nexus-ioc/testing": "^0.6.1", "@types/node": "^24.8.1", "@vitest/coverage-v8": "^3.2.4", @@ -18784,7 +18784,7 @@ }, "packages/ioc": { "name": "@nexus-ioc/core", - "version": "0.6.2", + "version": "1.0.0", "license": "MIT", "dependencies": { "@nexus-ioc/shared": "^0.6.1", @@ -18794,7 +18794,6 @@ "@types/node": "^24.8.1", "@vitest/coverage-v8": "^3.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", - "sinon": "^21.0.0", "typescript": "^5.9.3", "vite": "^7.0.0", "vitest": "^3.0.0" @@ -18803,15 +18802,6 @@ "reflect-metadata": "^0.1.12 || ^0.2.0" } }, - "packages/ioc/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, "packages/ioc/node_modules/@types/node": { "version": "24.8.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz", @@ -18821,32 +18811,6 @@ "undici-types": "~7.14.0" } }, - "packages/ioc/node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, - "packages/ioc/node_modules/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "supports-color": "^7.2.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/sinon" - } - }, "packages/ioc/node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", @@ -19758,7 +19722,7 @@ "tslib": "^2.7.0" }, "devDependencies": { - "@nexus-ioc/core": "^0.6.1", + "@nexus-ioc/core": "^1.0.0", "@types/node": "^24.8.1", "@vitest/coverage-v8": "^3.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1fc007b7..1b06100a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,7 +51,7 @@ "sideEffects": false, "license": "MIT", "devDependencies": { - "@nexus-ioc/core": "^0.6.1", + "@nexus-ioc/core": "^1.0.0", "@nexus-ioc/testing": "^0.6.1", "@types/node": "^24.8.1", "@vitest/coverage-v8": "^3.2.4", diff --git a/packages/testing/package.json b/packages/testing/package.json index 2d403561..7501439e 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -46,7 +46,7 @@ "reflect-metadata": "^0.1.12 || ^0.2.0" }, "devDependencies": { - "@nexus-ioc/core": "^0.6.1", + "@nexus-ioc/core": "^1.0.0", "@types/node": "^24.8.1", "@vitest/coverage-v8": "^3.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0",