diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bcb361..47bab4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3.1.1 with: - node-version: 16.x + node-version: 20.x - name: Restore cache uses: actions/cache@v3.2.4 with: @@ -28,13 +28,12 @@ jobs: run: node common/scripts/install-run-rush.js check - name: Assert Changelogs run: node common/scripts/install-run-rush.js change --verify - test-nest-cqrs: + test-packages: needs: verify runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x] - package: [core, eventstoredb, 'nats'] + node-version: [20.x] steps: - name: Checkout uses: actions/checkout@v3 @@ -48,13 +47,13 @@ jobs: path: common/temp/pnpm-store key: ${{ runner.os }}-{{ hashFiles('common/config/rush/pnpm-lock.yaml') }} - name: Rush Install - run: node common/scripts/install-run-rush.js install + run: node common/scripts/install-run-rush.js install --to tag:package - name: Build run: | - node common/scripts/install-run-rush.js build -T @nest-cqrs/${{ matrix.package }} + node common/scripts/install-run-rush.js build --to tag:package - name: Test run: | - npm run test --prefix packages/${{ matrix.package }} + node common/scripts/install-run-rush.js test --to tag:package publsh: runs-on: ubuntu-latest needs: [verify] @@ -72,8 +71,8 @@ jobs: path: common/temp/pnpm-store key: ${{ runner.os }}-{{ hashFiles('common/config/rush/pnpm-lock.yaml') }} - name: Rush Install - run: node common/scripts/install-run-rush.js install + run: node common/scripts/install-run-rush.js install --to tag:package - name: Rush Build - run: node common/scripts/install-run-rush.js build + run: node common/scripts/install-run-rush.js build --to tag:package - name: Publish run: node common/scripts/install-run-rush.js publish --publish -n ${{ secrets.NPM_TOKEN }} --include-all --set-access-level public diff --git a/common/changes/@dtty/simpldi/add-simpldi_2024-11-11-16-06.json b/common/changes/@dtty/simpldi/add-simpldi_2024-11-11-16-06.json new file mode 100644 index 0000000..3ecb3d3 --- /dev/null +++ b/common/changes/@dtty/simpldi/add-simpldi_2024-11-11-16-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@dtty/simpldi", + "comment": "Initial release", + "type": "minor" + } + ], + "packageName": "@dtty/simpldi" +} diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index 431a37d..caf1fdd 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -25,6 +25,7 @@ "summary": "Run test scripts in each of the packages in the project", "safeForSimultaneousRushProcesses": false, "shellCommand": "rushx test", + "allowWarningsInSuccessfulBuild": true, "enableParallelism": true } // { diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 0056525..23094c3 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -9,6 +9,10 @@ importers: .: {} ../../packages/simpldi: + dependencies: + reflect-metadata: + specifier: ~0.2.2 + version: 0.2.2 devDependencies: '@swc/core': specifier: ~1.9.1 @@ -2174,6 +2178,10 @@ packages: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} dev: true + /reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + dev: false + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} diff --git a/packages/simpldi/README.md b/packages/simpldi/README.md new file mode 100644 index 0000000..63d56da --- /dev/null +++ b/packages/simpldi/README.md @@ -0,0 +1,78 @@ +# @dtty/simpldi + +A simple dependency injection library for typescript projects. + +## Installation + +This library requires the `reflect-metadata` package to function properly. +Add the line `import 'reflect-metadata';` to the top of you entry file (e.g. index.js). + +```sh +npm i @dtty/simpldi reflect-metadata +``` + +## Usage + +Unlike many other dependency injection libraries for Typescript projects, `@dtty/simpldi` requires developers to manage the container lifecycle themselvs. +While some may consider this a burden, it ultimately allows for far more flexible configurations and a simpler API to engage with. + +```ts +// Basic usage + +const rootContainer = new Container(); + +// Add a simple provider +rootContainer.addProvider(simpleToken, SimpleProviderClass); + +// Add a transient provider +rootContainer.addProvider(transientToken, TransientProviderClass, { + mode: ProviderMode.TRANSIENT, +}); + +// Fetch a provider +const instance = await rootContainer.resolveProvider(simpleToken); +``` + +### Tokens + +Tokens in `@dtty/simpldi` are used in place of magic strings for dependency resolution. +This enables stricter type checking when manually resolving dependencies. + +```ts +// Create a token +const token = new Token(); + +// Optionall, add a name to the token +const tokenWithName = new Token("My Name"); +``` + +### Modes + +By default, all providers in the container are registered as singletons. +This behavior can be optionally changed by setting `mode: ProviderMode.TRANSIENT` when registering a provider. +Transient providers will be re-initialized each time they are resolved, ensuring all downstream providers recieve a fresh instance. + +### Nested Containers + +Providers are always scoped to their container but can depend on providers from a parent container. +Creating a child container allows developers to scope specific providers to the lifetime of the container. for example the use of a child container for request-specific providers in a web server. + +```ts +// Start with a root +const rootContainer = new Container(); + +// And spawn a child +const childContainer = rootContainer.createChildContainer(); +``` + +### Lifecycle + +By default, all providers a lazy loaded into the container, meaning that provider instances are only created when the are needed. +Lazy loading enables a faster application start time and ensures that only the relevant classes are created, cutting down on overhead. + +Below are ways to hook into the provider lifecycle. + +#### onProviderInit + +By implementing the `IOnProviderInit` interface, a provider adds a lifecycle method that is called whenever the provider instance is resolved in the container. +This can be useful for bootstrapping common providers like database connections that require an asynchronous interaction after creation. diff --git a/packages/simpldi/package.json b/packages/simpldi/package.json index cbccbc8..5393820 100644 --- a/packages/simpldi/package.json +++ b/packages/simpldi/package.json @@ -1,5 +1,5 @@ { - "name": "@ditty/simpldi", + "name": "@dtty/simpldi", "version": "0.0.0", "description": "Simple and compact Dependency Injection library for TS", "license": "ISC", @@ -10,6 +10,9 @@ "build": "npm run prebuild && tsc -p tsconfig.build.json", "test": "env JEST_ROOT_DIR=packages/simpldi jest --config ../../jest.config.js" }, + "dependencies": { + "reflect-metadata": "~0.2.2" + }, "devDependencies": { "@swc/core": "~1.9.1", "@swc/jest": "~0.2.37", diff --git a/packages/simpldi/src/container.spec.ts b/packages/simpldi/src/container.spec.ts new file mode 100644 index 0000000..6e27bc0 --- /dev/null +++ b/packages/simpldi/src/container.spec.ts @@ -0,0 +1,145 @@ +import { Container } from "./container"; +import { Inject } from "./inject"; +import { ProviderNotFoundException } from "./provider-not-found.exception"; +import { Token } from "./token"; +import { IOnProviderInit, ProviderMode } from "./types"; + +describe("Container interactions", () => { + let rootContainer: Container; + class ProviderWithNoDeps { + public readonly Id: string; + constructor() { + this.Id = (Math.random() * Date.now()).toString(); + } + } + + const noDepsToken = new Token("No deps"); + + class ProviderWithOneDep extends ProviderWithNoDeps { + constructor(@Inject(noDepsToken) public readonly dep: ProviderWithNoDeps) { + super(); + } + } + + const oneDepToken = new Token("One Dep"); + + class ProviderWithMultipleDeps extends ProviderWithNoDeps { + constructor( + @Inject(noDepsToken) public readonly noDeps: ProviderWithNoDeps, + @Inject(oneDepToken) public readonly oneDep: ProviderWithOneDep, + ) { + super(); + } + } + + const mulipleDepToken = new Token("many deps"); + + beforeEach(() => { + rootContainer = new Container(); + }); + + it("should create a child container linked to the root", () => { + const child = rootContainer.createChildContainer(); + expect(child["parent"]).toEqual(rootContainer); + }); + + describe("Single layer provider lifecycle", () => { + it("should resolve a single provider with no dependencies", async () => { + rootContainer.addProvider(noDepsToken, ProviderWithNoDeps); + const provider = await rootContainer.resolveProvider(noDepsToken); + expect(provider).toBeInstanceOf(ProviderWithNoDeps); + expect(provider.Id).not.toBeUndefined(); + }); + + it("should always resolve the same provider instance by default", async () => { + rootContainer.addProvider(noDepsToken, ProviderWithNoDeps); + const provider1 = await rootContainer.resolveProvider(noDepsToken); + const provider2 = await rootContainer.resolveProvider(noDepsToken); + expect(provider1.Id).toEqual(provider2.Id); + }); + + it("should resolve a single provider with a singleton dependency", async () => { + rootContainer.addProvider(noDepsToken, ProviderWithNoDeps); + rootContainer.addProvider(oneDepToken, ProviderWithOneDep); + + const provider = await rootContainer.resolveProvider(oneDepToken); + expect(provider).toBeInstanceOf(ProviderWithOneDep); + expect(provider.dep).toBeInstanceOf(ProviderWithNoDeps); + expect(provider.Id).not.toEqual(provider.dep.Id); + }); + + it("should resolve a single provider with a transient dependency", async () => { + rootContainer.addProvider(oneDepToken, ProviderWithOneDep); + rootContainer.addProvider(noDepsToken, ProviderWithNoDeps, { + mode: ProviderMode.TRANSIENT, + }); + const provider1 = await rootContainer.resolveProvider(oneDepToken); + const provider2 = await rootContainer.resolveProvider(noDepsToken); + expect(provider1.dep.Id).not.toEqual(provider2.Id); + }); + + it("should resolve a provider with multiple dependencies", async () => { + rootContainer.addProvider(noDepsToken, ProviderWithNoDeps); + rootContainer.addProvider(oneDepToken, ProviderWithOneDep); + rootContainer.addProvider(mulipleDepToken, ProviderWithMultipleDeps); + const provider = await rootContainer.resolveProvider(mulipleDepToken); + expect(provider).toBeInstanceOf(ProviderWithMultipleDeps); + expect(provider.noDeps.Id).toEqual(provider.oneDep.dep.Id); + }); + + it("should throw an exception when resolving a missing provider", async () => { + await expect(() => + rootContainer.resolveProvider(noDepsToken), + ).rejects.toThrow(ProviderNotFoundException); + }); + }); + + describe("Multi layer provider lifecycle", () => { + let childContainer: Container; + + beforeEach(() => { + childContainer = rootContainer.createChildContainer(); + }); + + it("should resolve a provider from the child using a parent dependency", async () => { + rootContainer.addProvider(noDepsToken, ProviderWithNoDeps); + childContainer.addProvider(oneDepToken, ProviderWithOneDep); + const provider1 = await childContainer.resolveProvider(oneDepToken); + const provider2 = await rootContainer.resolveProvider(noDepsToken); + expect(provider1.dep.Id).toEqual(provider2.Id); + }); + + it("should throw an exception when resolving a provider that depends on the child", async () => { + childContainer.addProvider(noDepsToken, ProviderWithNoDeps); + rootContainer.addProvider(oneDepToken, ProviderWithOneDep); + await expect(() => + childContainer.resolveProvider(oneDepToken), + ).rejects.toThrow(ProviderNotFoundException); + }); + }); + + describe("Provider lifecycle methods", () => { + class InitAbleProvider + extends ProviderWithNoDeps + implements IOnProviderInit + { + public isInit: boolean; + + constructor() { + super(); + this.isInit = false; + } + public onProviderInit(): void { + this.isInit = true; + } + } + + const initToken = new Token(); + + it("should call onProviderInit if present when constructing a provider instance", async () => { + rootContainer.addProvider(initToken, InitAbleProvider); + const provider = await rootContainer.resolveProvider(initToken); + expect(provider.isInit).toEqual(true); + }); + }); +}); diff --git a/packages/simpldi/src/container.ts b/packages/simpldi/src/container.ts new file mode 100644 index 0000000..85c8715 --- /dev/null +++ b/packages/simpldi/src/container.ts @@ -0,0 +1,92 @@ +import { INJECT_META_KEY } from "./inject"; +import { ProviderNotFoundException } from "./provider-not-found.exception"; +import { Token } from "./token"; +import { + Constructable, + IClassProvider, + IOnProviderInit, + Provider, + ProviderMode, + ProviderType, +} from "./types"; + +/** + * Dependency injection container + */ +export class Container { + private parent: Container; + private providers: Map, Provider>; + + constructor() { + this.providers = new Map(); + } + + /** + * Create a new container instance linked to a parent, + * where the child can access providers from the parent + * but not vice versa. Useful for scoping dependencies to + * the lifetime of a child container. + */ + public createChildContainer(): Container { + const newContainer = new Container(); + newContainer.parent = this; + return newContainer; + } + + /** + * Add a provider to the container using a token and a type + **/ + public addProvider( + token: Token, + Constructor: Constructable, + options: Pick>, "mode"> = {}, + ): void { + const { mode = ProviderMode.SINGLETON } = options; + const provider: IClassProvider = { + type: ProviderType.CLASS, + token, + Constructor, + inject: + (Reflect as any).getOwnMetadata( + INJECT_META_KEY, + Constructor, + undefined, + ) || [], + mode, + }; + this.providers.set(token, provider); + } + + /** + * Resolve a provider from this or a parent container + */ + public async resolveProvider(token: Token): Promise { + if (this.providers.has(token)) { + const provider = this.providers.get(token) as Provider; + return provider.instance || (await this.constructInstance(provider)); + } + if (this.parent) { + return this.parent.resolveProvider(token); + } + throw new ProviderNotFoundException(token); + } + + private async constructInstance(provider: Provider): Promise { + let instance: T; + const injectInstances = []; + for (const token of provider.inject) { + injectInstances.push(await this.resolveProvider(token)); + } + switch (provider.type) { + case ProviderType.CLASS: + instance = new (provider as IClassProvider).Constructor( + ...injectInstances, + ); + break; + } + await (instance as Partial).onProviderInit?.(); + if (provider.mode == ProviderMode.SINGLETON) + this.providers.set(provider.token, { ...provider, instance }); + return instance; + } +} diff --git a/packages/simpldi/src/index.ts b/packages/simpldi/src/index.ts index e69de29..6cde813 100644 --- a/packages/simpldi/src/index.ts +++ b/packages/simpldi/src/index.ts @@ -0,0 +1,12 @@ +export { Container } from "./container"; +export { Inject } from "./inject"; +export { ProviderNotFoundException } from "./provider-not-found.exception"; +export { Token } from "./token"; +export { + ProviderType, + ProviderMode, + Constructable, + Provider, + IClassProvider, + IOnProviderInit, +} from "./types"; diff --git a/packages/simpldi/src/inject.ts b/packages/simpldi/src/inject.ts new file mode 100644 index 0000000..c5a7c1b --- /dev/null +++ b/packages/simpldi/src/inject.ts @@ -0,0 +1,18 @@ +import { Token } from "./token"; + +export const INJECT_META_KEY = Symbol("inject_meta"); + +export const Inject = + (token: Token): ParameterDecorator => + (target, propertyKey, parameterIndex) => { + const tokens = + (Reflect as any).getOwnMetadata(INJECT_META_KEY, target, propertyKey) || + []; + tokens[parameterIndex] = token; + (Reflect as any).defineMetadata( + INJECT_META_KEY, + tokens, + target, + propertyKey, + ); + }; diff --git a/packages/simpldi/src/provider-not-found.exception.ts b/packages/simpldi/src/provider-not-found.exception.ts new file mode 100644 index 0000000..fb5b736 --- /dev/null +++ b/packages/simpldi/src/provider-not-found.exception.ts @@ -0,0 +1,8 @@ +import { Token } from "./token"; + +export class ProviderNotFoundException extends Error { + constructor(public readonly token: Token) { + super(`Cannot find provider for ${token}`); + this.name = this.constructor.name; + } +} diff --git a/packages/simpldi/src/token.ts b/packages/simpldi/src/token.ts new file mode 100644 index 0000000..e54aab0 --- /dev/null +++ b/packages/simpldi/src/token.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class Token { + constructor(public readonly name?: string) {} +} diff --git a/packages/simpldi/src/types.ts b/packages/simpldi/src/types.ts new file mode 100644 index 0000000..ea9e4a9 --- /dev/null +++ b/packages/simpldi/src/types.ts @@ -0,0 +1,31 @@ +import { Token } from "./token"; + +export enum ProviderType { + CLASS, +} + +export enum ProviderMode { + SINGLETON, + TRANSIENT, +} + +export interface Constructable { + new (...args: any[]): T; +} + +export interface Provider { + type: ProviderType; + token: Token; + instance?: T; + inject: Token[]; + mode: ProviderMode; +} + +export interface IClassProvider extends Provider { + type: ProviderType.CLASS; + Constructor: Constructable; +} + +export interface IOnProviderInit { + onProviderInit(): void | Promise; +} diff --git a/rush.json b/rush.json index 2094285..7724c9c 100644 --- a/rush.json +++ b/rush.json @@ -331,9 +331,10 @@ */ "projects": [ { - "packageName": "@ditty/simpldi", + "packageName": "@dtty/simpldi", "projectFolder": "packages/simpldi", - "shouldPublish": true + "shouldPublish": true, + "tags": ["package"] } // { // /**