refactor(core): Replace typedi with our custom DI system (no-changelog) (#12389)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-01-06 10:21:24 +01:00
committed by GitHub
parent 8053a4a176
commit 39d5e0ff87
413 changed files with 979 additions and 452 deletions

View File

@@ -0,0 +1,7 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/** @type {import('@types/eslint').ESLint.ConfigData} */
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
...sharedOptions(__dirname),
};

View File

@@ -0,0 +1,52 @@
## @n8n/di
`@n8n/di` is a dependency injection (DI) container library, based on [`typedi`](https://github.com/typestack/typedi).
n8n no longer uses `typedi` because:
- `typedi` is no longer officially maintained
- Need for future-proofing, e.g. stage-3 decorators
- Small enough that it is worth the maintenance burden
- Easier to customize, e.g. to simplify unit tests
### Usage
```typescript
// from https://github.com/typestack/typedi/blob/develop/README.md
import { Container, Service } from 'typedi';
@Service()
class ExampleInjectedService {
printMessage() {
console.log('I am alive!');
}
}
@Service()
class ExampleService {
constructor(
// because we annotated ExampleInjectedService with the @Service()
// decorator TypeDI will automatically inject an instance of
// ExampleInjectedService here when the ExampleService class is requested
// from TypeDI.
public injectedService: ExampleInjectedService
) {}
}
const serviceInstance = Container.get(ExampleService);
// we request an instance of ExampleService from TypeDI
serviceInstance.injectedService.printMessage();
// logs "I am alive!" to the console
```
Requires enabling these flags in `tsconfig.json`:
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
```

View File

@@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');

View File

@@ -0,0 +1,26 @@
{
"name": "@n8n/di",
"version": "0.1.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint .",
"lintfix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"
},
"main": "dist/di.js",
"module": "src/di.ts",
"types": "dist/di.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"reflect-metadata": "catalog:"
}
}

View File

@@ -0,0 +1,287 @@
import { Container, Service } from '../di';
@Service()
class SimpleService {
getValue() {
return 'simple';
}
}
@Service()
class DependentService {
constructor(readonly simple: SimpleService) {}
getValue() {
return this.simple.getValue() + '-dependent';
}
}
class CustomFactory {
getValue() {
return 'factory-made';
}
}
@Service({ factory: () => new CustomFactory() })
class FactoryService {
getValue() {
return 'should-not-be-called';
}
}
abstract class AbstractService {
abstract getValue(): string;
}
@Service()
class ConcreteService extends AbstractService {
getValue(): string {
return 'concrete';
}
}
describe('DI Container', () => {
beforeEach(() => {
jest.clearAllMocks();
Container.reset();
});
describe('basic functionality', () => {
it('should create a simple instance', () => {
const instance = Container.get(SimpleService);
expect(instance).toBeInstanceOf(SimpleService);
expect(instance.getValue()).toBe('simple');
});
it('should return same instance on multiple gets', () => {
const instance1 = Container.get(SimpleService);
const instance2 = Container.get(SimpleService);
expect(instance1).toBe(instance2);
});
it('should handle classes with no dependencies (empty constructor)', () => {
@Service()
class EmptyConstructorService {}
const instance = Container.get(EmptyConstructorService);
expect(instance).toBeInstanceOf(EmptyConstructorService);
});
it('should throw when trying to resolve an undecorated class', () => {
class UnDecoratedService {}
expect(() => Container.get(UnDecoratedService)).toThrow();
});
});
describe('dependency injection', () => {
it('should inject dependencies correctly', () => {
const dependent = Container.get(DependentService);
expect(dependent).toBeInstanceOf(DependentService);
expect(dependent.getValue()).toBe('simple-dependent');
expect(dependent.simple).toBeInstanceOf(SimpleService);
});
it('should handle deep dependency chains', () => {
@Service()
class ServiceC {
getValue() {
return 'C';
}
}
@Service()
class ServiceB {
constructor(private c: ServiceC) {}
getValue() {
return this.c.getValue() + 'B';
}
}
@Service()
class ServiceA {
constructor(private b: ServiceB) {}
getValue() {
return this.b.getValue() + 'A';
}
}
const instance = Container.get(ServiceA);
expect(instance.getValue()).toBe('CBA');
});
it('should return undefined for non-decorated dependencies in resolution chain', () => {
class NonDecoratedDep {}
@Service()
class ServiceWithNonDecoratedDep {
constructor(readonly dep: NonDecoratedDep) {}
}
const instance = Container.get(ServiceWithNonDecoratedDep);
expect(instance).toBeInstanceOf(ServiceWithNonDecoratedDep);
expect(instance.dep).toBeUndefined();
});
});
describe('factory handling', () => {
it('should use factory when provided', () => {
const instance = Container.get(FactoryService);
expect(instance).toBeInstanceOf(CustomFactory);
expect(instance.getValue()).toBe('factory-made');
});
it('should preserve factory metadata when setting instance', () => {
const customInstance = new CustomFactory();
Container.set(FactoryService, customInstance);
const instance = Container.get(FactoryService);
expect(instance).toBe(customInstance);
});
it('should preserve factory when resetting container', () => {
const factoryInstance1 = Container.get(FactoryService);
Container.reset();
const factoryInstance2 = Container.get(FactoryService);
expect(factoryInstance1).not.toBe(factoryInstance2);
expect(factoryInstance2.getValue()).toBe('factory-made');
});
it('should throw error when factory throws', () => {
@Service({
factory: () => {
throw new Error('Factory error');
},
})
class ErrorFactoryService {}
expect(() => Container.get(ErrorFactoryService)).toThrow('Factory error');
});
});
describe('instance management', () => {
it('should allow manual instance setting', () => {
const customInstance = new SimpleService();
Container.set(SimpleService, customInstance);
const instance = Container.get(SimpleService);
expect(instance).toBe(customInstance);
});
});
describe('abstract classes', () => {
it('should throw when trying to instantiate an abstract class directly', () => {
@Service()
abstract class TestAbstractClass {
abstract doSomething(): void;
// Add a concrete method to make the class truly abstract at runtime
constructor() {
if (this.constructor === TestAbstractClass) {
throw new TypeError('Abstract class "TestAbstractClass" cannot be instantiated');
}
}
}
expect(() => Container.get(TestAbstractClass)).toThrow(
'[DI] TestAbstractClass is an abstract class, and cannot be instantiated',
);
});
it('should allow setting an implementation for an abstract class', () => {
const concrete = new ConcreteService();
Container.set(AbstractService, concrete);
const instance = Container.get(AbstractService);
expect(instance).toBe(concrete);
expect(instance.getValue()).toBe('concrete');
});
it('should allow factory for abstract class', () => {
@Service({ factory: () => new ConcreteService() })
abstract class FactoryAbstractService {
abstract getValue(): string;
}
const instance = Container.get(FactoryAbstractService);
expect(instance).toBeInstanceOf(ConcreteService);
expect(instance.getValue()).toBe('concrete');
});
});
describe('inheritance', () => {
it('should handle inheritance in injectable classes', () => {
@Service()
class BaseService {
getValue() {
return 'base';
}
}
@Service()
class DerivedService extends BaseService {
getValue() {
return 'derived-' + super.getValue();
}
}
const instance = Container.get(DerivedService);
expect(instance.getValue()).toBe('derived-base');
});
it('should maintain separate instances for base and derived classes', () => {
@Service()
class BaseService {
getValue() {
return 'base';
}
}
@Service()
class DerivedService extends BaseService {}
const baseInstance = Container.get(BaseService);
const derivedInstance = Container.get(DerivedService);
expect(baseInstance).not.toBe(derivedInstance);
expect(baseInstance).toBeInstanceOf(BaseService);
expect(derivedInstance).toBeInstanceOf(DerivedService);
});
});
describe('type registration checking', () => {
it('should return true for registered classes', () => {
expect(Container.has(SimpleService)).toBe(true);
});
it('should return false for unregistered classes', () => {
class UnregisteredService {}
expect(Container.has(UnregisteredService)).toBe(false);
});
it('should return true for abstract classes with implementations', () => {
const concrete = new ConcreteService();
Container.set(AbstractService, concrete);
expect(Container.has(AbstractService)).toBe(true);
});
it('should return true for factory-provided services before instantiation', () => {
expect(Container.has(FactoryService)).toBe(true);
});
it('should maintain registration after reset', () => {
expect(Container.has(SimpleService)).toBe(true);
Container.reset();
expect(Container.has(SimpleService)).toBe(true);
});
it('should return true after manual instance setting', () => {
class ManualService {}
expect(Container.has(ManualService)).toBe(false);
Container.set(ManualService, new ManualService());
expect(Container.has(ManualService)).toBe(true);
});
});
});

142
packages/@n8n/di/src/di.ts Normal file
View File

@@ -0,0 +1,142 @@
import 'reflect-metadata';
/**
* Represents a class constructor type that can be instantiated with 'new'
* @template T The type of instance the constructor creates
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructable<T = unknown> = new (...args: any[]) => T;
type AbstractConstructable<T = unknown> = abstract new (...args: unknown[]) => T;
type ServiceIdentifier<T = unknown> = Constructable<T> | AbstractConstructable<T>;
interface Metadata<T = unknown> {
instance?: T;
factory?: () => T;
}
interface Options<T> {
factory?: () => T;
}
const instances = new Map<ServiceIdentifier, Metadata>();
/**
* Decorator that marks a class as available for dependency injection.
* @param options Configuration options for the injectable class
* @param options.factory Optional factory function to create instances of this class
* @returns A class decorator to be applied to the target class
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export function Service<T = unknown>(): Function;
// eslint-disable-next-line @typescript-eslint/ban-types
export function Service<T = unknown>(options: Options<T>): Function;
export function Service<T>({ factory }: Options<T> = {}) {
return function (target: Constructable<T>) {
instances.set(target, { factory });
return target;
};
}
class DIError extends Error {
constructor(message: string) {
super(`[DI] ${message}`);
}
}
class ContainerClass {
/** Stack to track types being resolved to detect circular dependencies */
private readonly resolutionStack: ServiceIdentifier[] = [];
/**
* Checks if a type is registered in the container
* @template T The type to check for
* @param type The constructor of the type to check
* @returns True if the type is registered (has metadata), false otherwise
*/
has<T>(type: ServiceIdentifier<T>): boolean {
return instances.has(type);
}
/**
* Retrieves or creates an instance of the specified type from the container
* @template T The type of instance to retrieve
* @param type The constructor of the type to retrieve
* @returns An instance of the specified type with all dependencies injected
* @throws {DIError} If circular dependencies are detected or if the type is not injectable
*/
get<T>(type: ServiceIdentifier<T>): T {
const { resolutionStack } = this;
const metadata = instances.get(type) as Metadata<T>;
if (!metadata) {
// Special case: Allow undefined returns for non-decorated constructor params
// when resolving a dependency chain (i.e., resolutionStack not empty)
if (resolutionStack.length) return undefined as T;
throw new DIError(`${type.name} is not decorated with ${Service.name}`);
}
if (metadata?.instance) return metadata.instance as T;
// Check for circular dependencies before proceeding with instantiation
if (resolutionStack.includes(type)) {
throw new DIError(
`Circular dependency detected. ${resolutionStack.map((t) => t.name).join(' -> ')}`,
);
}
// Add current type to resolution stack before resolving dependencies
resolutionStack.push(type);
try {
let instance: T;
if (metadata?.factory) {
instance = metadata.factory();
} else {
const paramTypes = (Reflect.getMetadata('design:paramtypes', type) ??
[]) as Constructable[];
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>) =>
this.get(paramType),
);
// Create new instance with resolved dependencies
instance = new (type as Constructable)(...dependencies) as T;
}
instances.set(type, { ...metadata, instance });
return instance;
} catch (error) {
if (error instanceof TypeError && error.message.toLowerCase().includes('abstract')) {
throw new DIError(`${type.name} is an abstract class, and cannot be instantiated`);
}
throw error;
} finally {
resolutionStack.pop();
}
}
/**
* Manually sets an instance for a specific type in the container
* @template T The type of instance being set
* @param type The constructor of the type to set. This can also be an abstract class
* @param instance The instance to store in the container
*/
set<T>(type: ServiceIdentifier<T>, instance: T): void {
// Preserve any existing metadata (like factory) when setting new instance
const metadata = instances.get(type) ?? {};
instances.set(type, { ...metadata, instance });
}
/** Clears all instantiated instances from the container while preserving type registrations */
reset(): void {
for (const metadata of instances.values()) {
delete metadata.instance;
}
}
}
/**
* Global dependency injection container instance
* Used to retrieve and manage class instances and their dependencies
*/
export const Container = new ContainerClass();

View File

@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "../../../tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**"]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}