mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
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:
committed by
GitHub
parent
8053a4a176
commit
39d5e0ff87
7
packages/@n8n/di/.eslintrc.js
Normal file
7
packages/@n8n/di/.eslintrc.js
Normal 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),
|
||||
};
|
||||
52
packages/@n8n/di/README.md
Normal file
52
packages/@n8n/di/README.md
Normal 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
|
||||
}
|
||||
}
|
||||
```
|
||||
2
packages/@n8n/di/jest.config.js
Normal file
2
packages/@n8n/di/jest.config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
26
packages/@n8n/di/package.json
Normal file
26
packages/@n8n/di/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
287
packages/@n8n/di/src/__tests__/di.test.ts
Normal file
287
packages/@n8n/di/src/__tests__/di.test.ts
Normal 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
142
packages/@n8n/di/src/di.ts
Normal 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();
|
||||
11
packages/@n8n/di/tsconfig.build.json
Normal file
11
packages/@n8n/di/tsconfig.build.json
Normal 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__/**"]
|
||||
}
|
||||
12
packages/@n8n/di/tsconfig.json
Normal file
12
packages/@n8n/di/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user