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/ioc/README.md b/packages/ioc/README.md index 5a1580aa..1ec6cec2 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,95 @@ 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); +``` + +`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: + +- 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. + +### 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 +@Injectable() +class OrdersService { + constructor(@Inject(LazyModuleLoader) private readonly loader: LazyModuleLoader) {} + + async check(order: Order) { + const ref = await this.loader.load(FraudLazy); + 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 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..21c1190c --- /dev/null +++ b/packages/ioc/__test__/lazy/application-load.spec.ts @@ -0,0 +1,185 @@ +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("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() + 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/__test__/lazy/compile-segment.spec.ts b/packages/ioc/__test__/lazy/compile-segment.spec.ts new file mode 100644 index 00000000..2a330ec7 --- /dev/null +++ b/packages/ioc/__test__/lazy/compile-segment.spec.ts @@ -0,0 +1,248 @@ +import "reflect-metadata"; +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"; + +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("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", + }); + @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/__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/__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/__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/__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/__test__/lazy/lazy-module-loader.spec.ts b/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts new file mode 100644 index 00000000..9da9974d --- /dev/null +++ b/packages/ioc/__test__/lazy/lazy-module-loader.spec.ts @@ -0,0 +1,95 @@ +import "reflect-metadata"; +import { + Inject, + Injectable, + LazyModuleLoader, + lazy, + Module, + NexusApplication, + type Node, + NodeTypeEnum, +} from "../../src"; +import type { AnalyzeModule } from "../../src/core/graph/analyze-module"; + +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 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(); + }); +}); 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/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..1aa994d3 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,9 +22,11 @@ import type { ForwardRef } from "../../utils/forward-ref"; import { isForwardRef } from "../../utils/forward-ref"; import { getDependencyToken, + getModuleLabel, getProviderToken, isModule, } from "../../utils/helpers"; +import { AnalyzeLazyModule } from "./analyze-lazy-module"; import { AnalyzeModule } from "./analyze-module"; import type { AnalyzeProvider } from "./analyze-provider"; import { @@ -42,23 +46,23 @@ 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(); private _globalModules: Map = new Map(); private readonly _errors: GraphError[] = []; - - constructor(private readonly _root: ModuleContainerInterface) {} + /** + * 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, + private readonly _internalRoots: ModuleContainerInterface[] = [], + ) {} public get nodes() { return this._nodes; @@ -72,11 +76,100 @@ 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 + * are checked by `isProviderExported`. + */ public async compile() { - await this.addModules(); - await this.addDependencies(); - await this.detectCircularDependencies(); - await this.detectCircularImports(); + const moduleTokens: string[] = []; + const providerTokens: InjectionToken[] = []; + + 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); + } + + /** + * 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 + * (every node, edge, global registration and LAZY placeholder it created is + * 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, + lazyModule: LazyModule, + ): Promise { + const createdPlaceholder = !this._nodes.has(lazyModule.id); + + if (createdPlaceholder) { + this.addNode(lazyModule.id, new AnalyzeLazyModule(lazyModule)); + } + + 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, + }; + } finally { + this._errorSink = previousSink; + } + } + + 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 { @@ -96,9 +189,19 @@ export class ModuleGraph implements ModuleGraphInterface { } // modules analyze - private async addModules() { + private async addModules( + root: ModuleContainerInterface, + strictTokens: boolean, + ): Promise<{ + moduleTokens: string[]; + providerTokens: InjectionToken[]; + lazyTokens: symbol[]; + }> { + const moduleTokens: string[] = []; + const providerTokens: InjectionToken[] = []; + const lazyTokens: symbol[] = []; const visited = new Set(); - const imports = [this._root]; + const imports = [root]; while (imports.length) { const importModule = imports.shift(); @@ -107,15 +210,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); - await this.addModuleImports(analyzeModule); - await this.addModuleProviders(analyzeModule); + moduleTokens.push(analyzeModule.id); + lazyTokens.push(...(await this.addModuleImports(analyzeModule))); + providerTokens.push( + ...(await this.addModuleProviders(analyzeModule, strictTokens)), + ); imports.push(...(await analyzeModule.imports)); - visited.add(analyzeModule.id); } + + return { moduleTokens, providerTokens, lazyTokens }; } private async addModule(analyzeModule: AnalyzeModule) { @@ -126,15 +240,41 @@ 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) { this.addEdge(analyzeModule.id, importEdge); } + + 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(analyzeModule: AnalyzeModule) { + private async addModuleProviders( + analyzeModule: AnalyzeModule, + strictTokens: boolean, + ): Promise { + const added: InjectionToken[] = []; + for (const provider of analyzeModule.providers) { const analyzeProvider = ProviderFactory( provider, @@ -145,9 +285,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.pushError({ + 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 @@ -166,12 +329,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; } @@ -210,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), @@ -248,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), @@ -292,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), @@ -326,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), @@ -367,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), @@ -393,7 +557,7 @@ export class ModuleGraph implements ModuleGraphInterface { } // providers dependencies - private async isProviderExported( + public async isProviderExported( moduleContainer: ModuleContainerInterface, dependencyToken: InjectionToken, ): Promise { @@ -457,7 +621,9 @@ export class ModuleGraph implements ModuleGraphInterface { return false; } - private async detectCircularDependencies(): Promise { + private async detectCircularDependencies( + startTokens: InjectionToken[], + ): Promise { const visit = ( nodeId: InjectionToken, path: InjectionToken[], @@ -494,7 +660,7 @@ export class ModuleGraph implements ModuleGraphInterface { }); if (!hasForwardRef) { - this.errors.push({ + this.pushError({ type: "CD_PROVIDERS", path: cyclePath, }); @@ -521,16 +687,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[], @@ -549,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)) @@ -582,11 +744,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/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/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/modules/container.ts b/packages/ioc/src/core/modules/container.ts index c4605a40..c2c7ae93 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,12 @@ 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. @@ -143,6 +156,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 * @@ -153,16 +168,100 @@ 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); 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; + } + + /** + * 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 { + 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 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); + } + + return segment; + } + /** * Gets the dependency graph. * diff --git a/packages/ioc/src/core/modules/module-container.ts b/packages/ioc/src/core/modules/module-container.ts index b9a7f75d..133c42cb 100644 --- a/packages/ioc/src/core/modules/module-container.ts +++ b/packages/ioc/src/core/modules/module-container.ts @@ -3,12 +3,14 @@ import type { ContainerBaseInterface, DynamicModule, InjectionToken, + LazyModule, ModuleContainerInterface, Provider, 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 = ""; @@ -30,31 +32,34 @@ export class ModuleContainer implements ModuleContainerInterface { return this._metatype; } - public get imports(): Promise { - let modules: (Type | DynamicModule)[]; + 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) => { - return self.container.addModule(item); - }), + 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 || []; 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..55d1df9e 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,8 @@ 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"; /** @@ -35,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. @@ -94,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); @@ -104,20 +112,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 +206,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/core/resolver/resolver.ts b/packages/ioc/src/core/resolver/resolver.ts index 69da14f9..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.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); diff --git a/packages/ioc/src/errors/bootstrap-error.ts b/packages/ioc/src/errors/bootstrap-error.ts index d5936693..ed49ee11 100644 --- a/packages/ioc/src/errors/bootstrap-error.ts +++ b/packages/ioc/src/errors/bootstrap-error.ts @@ -20,13 +20,18 @@ 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}`; } } +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 1e27d978..2008a269 100644 --- a/packages/ioc/src/index.ts +++ b/packages/ioc/src/index.ts @@ -1,13 +1,22 @@ // 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"; export { Global } from "./decorators/global"; 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"; 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/interfaces/modules/container.interface.ts b/packages/ioc/src/interfaces/modules/container.interface.ts index f851e850..61820675 100644 --- a/packages/ioc/src/interfaces/modules/container.interface.ts +++ b/packages/ioc/src/interfaces/modules/container.interface.ts @@ -1,12 +1,16 @@ -import type { ContainerBaseInterface } from "@nexus-ioc/shared"; +import type { ContainerBaseInterface, LazyModule } from "@nexus-ioc/shared"; +import type { DynamicModule } from "../dynamic-module.interface"; 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; + run(rootModule: Type, internalModules?: DynamicModule[]): Promise; + load(lazyModule: LazyModule): Promise; graph: ModuleGraphInterface; } diff --git a/packages/ioc/src/interfaces/modules/module-graph.interface.ts b/packages/ioc/src/interfaces/modules/module-graph.interface.ts index 7531e532..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,9 @@ -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"; import type { InjectionToken } from "../injection-token.interface"; @@ -9,6 +14,7 @@ export type { GraphError } from "@nexus-ioc/shared"; export enum NodeTypeEnum { MODULE = "module", PROVIDER = "provider", + LAZY = "lazy", } export enum EdgeTypeEnum { @@ -16,9 +22,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; @@ -34,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[]; 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[]; 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; 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/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" + ); +} 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; 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/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", 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;