From 0b492720569d538332b4cd9b4970721aa808f66d Mon Sep 17 00:00:00 2001 From: NeekoGta Date: Fri, 9 Jan 2026 15:47:51 +0100 Subject: [PATCH 1/3] chore: update README --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 17a7f87..34bf6e7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Aurora Multiplayer (aurora-mp) is a powerful TypeScript framework that lets you ## 🚀 Overview -Aurora Multiplayer abstracts away the differences between popular GTA multiplayer platforms (such as alt:V, FiveM, RAGE MP ...) so you can write one single, strongly-typed codebase that runs on all of them. +Aurora Multiplayer abstracts away the differences between popular GTA multiplayer platforms (such as FiveM, open.mp, RAGE MP ...) so you can write one single, strongly-typed codebase that runs on all of them. With built-in dependency injection, an event-driven architecture, and first-class TypeScript support, you spend less time wrestling with platform quirks and more time crafting immersive multiplayer experiences. ## 🔑 Key Features @@ -44,11 +44,10 @@ With built-in dependency injection, an event-driven architecture, and first-clas Aurora Multiplayer currently provides first-class support for the following multiplayer platforms: -- **alt:V** (WIP) – A modern, high-performance GTA V multiplayer platform with strong TypeScript support. - - *Will possibly be removed, not implemented now that it has become Majestic MP* -- **FiveM** (Not yet) – Community-driven GTA V multiplayer mod with a massive ecosystem (experimental support via plugins). -- **RAGE MP** (WIP, will be *finalized* soon) – A widely-used modding platform for GTA V, praised for its stability and extensive feature set. -- **YAMP** - *Maybe?* +- **FiveM** (WIP) – Community-driven GTA V multiplayer mod with a massive ecosystem (experimental support via plugins). +- **OMP (open.mp)** (WIP) – A brand new multiplayer mod for Grand Theft Auto: San Andreas that is fully backwards compatible with San Andreas Multiplayer. +- **RAGE MP** – A widely-used modding platform for GTA V, praised for its stability and extensive feature set. +- **YAMP** - (Soon) - **Other Runtimes** – Easily extendable: create adapters for any GTA multiplayer environment of your choice. ## 🛠️ Getting Started From 3d9c4cc90ba0d9ce08f4e339e49cdba427ec1802 Mon Sep 17 00:00:00 2001 From: NeekoGta Date: Fri, 9 Jan 2026 16:01:07 +0100 Subject: [PATCH 2/3] refactor(di): enhance Container with metadata, helpers and better errors --- packages/core/src/di/container.spec.ts | 236 ++++++++++++++++++++++--- packages/core/src/di/container.ts | 158 +++++++++++++++-- packages/core/src/errors/index.ts | 32 ++++ 3 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/errors/index.ts diff --git a/packages/core/src/di/container.spec.ts b/packages/core/src/di/container.spec.ts index e6c1423..d930fb2 100644 --- a/packages/core/src/di/container.spec.ts +++ b/packages/core/src/di/container.spec.ts @@ -1,4 +1,5 @@ -import { Container } from '../di'; +import { DuplicateProviderError, ProviderNotFoundError } from '../errors'; +import { Container, ProviderMetadata } from '../di'; describe('Container', () => { let container: Container; @@ -7,31 +8,226 @@ describe('Container', () => { container = new Container(); }); - it('should register and resolve a provider', () => { - const token = 'TOKEN'; - const value = 'VALUE'; - expect(container.has(token)).toBe(false); - container.register(token, value); - expect(container.has(token)).toBe(true); - expect(container.resolve(token)).toBe(value); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should throw when resolving an unregistered provider', () => { - expect(() => container.resolve('UNKNOWN')).toThrow('No provider found for token "UNKNOWN".'); + describe('register / resolve', () => { + it('registers and resolves a value by string token', () => { + container.register('TOKEN', 123); + expect(container.resolve('TOKEN')).toBe(123); + expect(container.has('TOKEN')).toBe(true); + }); + + it('registers and resolves a value by symbol token', () => { + const TOKEN = Symbol('MY_TOKEN'); + container.register(TOKEN, { ok: true }); + expect(container.resolve<{ ok: boolean }>(TOKEN)).toEqual({ ok: true }); + }); + + it('registers and resolves by class constructor token', () => { + class Service { + public value = 'hello'; + } + const instance = new Service(); + container.register(Service, instance); + + const resolved = container.resolve(Service); + expect(resolved).toBe(instance); + expect(resolved.value).toBe('hello'); + }); + + it('supports registering undefined as a valid provider value', () => { + container.register('UNDEF', undefined); + + expect(container.has('UNDEF')).toBe(true); + expect(container.resolve('UNDEF')).toBeUndefined(); + expect(container.tryResolve('UNDEF')).toBeUndefined(); + }); + + it('supports registering null as a valid provider value', () => { + container.register('NULL', null); + expect(container.resolve('NULL')).toBeNull(); + }); + + it('throws ProviderNotFoundError when resolving unknown token', () => { + expect(() => container.resolve('MISSING')).toThrow(ProviderNotFoundError); + expect(() => container.resolve('MISSING')).toThrow(/not found/i); + expect(() => container.resolve('MISSING')).toThrow(/MISSING/); + }); + + it('error message contains available tokens (when any exists)', () => { + container.register('A', 1); + container.register('B', 2); + + try { + container.resolve('MISSING'); + throw new Error('Expected resolve to throw'); + } catch (e) { + expect(e).toBeInstanceOf(ProviderNotFoundError); + const msg = (e as Error).message; + + expect(msg).toMatch(/Available\s*:/); + expect(msg).toContain('A'); + expect(msg).toContain('B'); + } + }); + + it('error message shows "none" when container is empty', () => { + try { + container.resolve('MISSING'); + throw new Error('Expected resolve to throw'); + } catch (e) { + expect(e).toBeInstanceOf(ProviderNotFoundError); + expect((e as Error).message).toMatch(/Available\s*:\s*none/); + } + }); + + it('limits available tokens list in error message and adds ellipsis when > limit', () => { + for (let i = 0; i < 12; i++) container.register(`T${i}`, i); + + try { + container.resolve('MISSING'); + throw new Error('Expected resolve to throw'); + } catch (e) { + expect((e as Error).message).toContain(', ...'); + } + }); + }); + + describe('override behavior', () => { + it('overrides existing provider by default (allowOverride=true)', () => { + container.register('TOKEN', 'v1'); + container.register('TOKEN', 'v2'); + expect(container.resolve('TOKEN')).toBe('v2'); + }); + + it('throws DuplicateProviderError when allowOverride=false and token exists', () => { + container.register('TOKEN', 'v1'); + + expect(() => container.register('TOKEN', 'v2', { allowOverride: false })).toThrow(DuplicateProviderError); + + // Original value preserved + expect(container.resolve('TOKEN')).toBe('v1'); + }); + + it('does not throw when allowOverride=false and token does not exist', () => { + expect(() => container.register('TOKEN', 'v1', { allowOverride: false })).not.toThrow(); + }); + }); + + describe('tryResolve', () => { + it('returns undefined when token not found', () => { + expect(container.tryResolve('MISSING')).toBeUndefined(); + }); + + it('returns the value when found', () => { + container.register('TOKEN', 42); + expect(container.tryResolve('TOKEN')).toBe(42); + }); }); - it('should allow overriding a provider and log a warning', () => { - const token = 'TOKEN'; - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + describe('metadata', () => { + it('stores metadata on first register (default scope=singleton)', () => { + container.register('TOKEN', 1); + + const meta = container.getMetadata('TOKEN') as ProviderMetadata | undefined; + expect(meta).toBeDefined(); + expect(meta?.scope).toBe('singleton'); + expect(meta?.overrides).toBe(0); + }); + + it('stores provided scope', () => { + container.register('TOKEN', 1, { scope: 'transient' }); + expect(container.getMetadata('TOKEN')?.scope).toBe('transient'); + }); + + it('preserves previous scope if overriding without explicit scope', () => { + container.register('TOKEN', 1, { scope: 'transient' }); + container.register('TOKEN', 2); // no scope provided => keep transient + + const meta = container.getMetadata('TOKEN')!; + expect(meta.scope).toBe('transient'); + expect(meta.overrides).toBeGreaterThanOrEqual(1); + }); + + it('returns undefined metadata when token is not registered', () => { + expect(container.getMetadata('MISSING')).toBeUndefined(); + }); + }); + + describe('getInstances / getTokens', () => { + it('getInstances() returns all registered values (in insertion order)', () => { + container.register('A', 1); + container.register('B', 2); + container.register('C', 3); + + expect(Array.from(container.getInstances())).toEqual([1, 2, 3]); + }); + + it('getTokens() returns all registered tokens (in insertion order)', () => { + const S = Symbol('S'); + class X {} + + container.register('A', 1); + container.register(S, 2); + container.register(X, new X()); + + const tokens = Array.from(container.getTokens()); + expect(tokens[0]).toBe('A'); + expect(tokens[1]).toBe(S); + expect(tokens[2]).toBe(X); + }); + }); + + describe('remove / clear', () => { + it('remove() returns false if token did not exist', () => { + expect(container.remove('MISSING')).toBe(false); + }); + + it('remove() removes provider and metadata', () => { + container.register('TOKEN', 1); + + expect(container.remove('TOKEN')).toBe(true); + expect(container.has('TOKEN')).toBe(false); + expect(container.getMetadata('TOKEN')).toBeUndefined(); + expect(() => container.resolve('TOKEN')).toThrow(ProviderNotFoundError); + }); + + it('clear() removes all providers and metadata', () => { + container.register('A', 1); + container.register('B', 2); + + container.clear(); + + expect(container.has('A')).toBe(false); + expect(container.has('B')).toBe(false); + expect(container.getMetadata('A')).toBeUndefined(); + expect(container.getMetadata('B')).toBeUndefined(); + expect(Array.from(container.getInstances())).toHaveLength(0); + }); + }); + + describe('snapshot', () => { + it('snapshot() returns providerCount and token strings', () => { + const S = Symbol('SNAP'); + class SnapService {} + + container.register('A', 1); + container.register(S, 2); + container.register(SnapService, new SnapService()); - container.register(token, 'v1'); - container.register(token, 'v2'); + const snap = container.snapshot(); + expect(snap.providerCount).toBe(3); + expect(snap.tokens).toEqual(expect.arrayContaining(['A', 'Symbol(SNAP)', 'SnapService'])); + }); - expect(consoleWarnSpy).toHaveBeenCalledWith( - `A provider with the token "${token}" is already registered. It will be overridden.`, - ); + it('snapshot() respects maxTokens', () => { + for (let i = 0; i < 30; i++) container.register(`T${i}`, i); - expect(container.resolve(token)).toBe('v2'); - consoleWarnSpy.mockRestore(); + const snap = container.snapshot(10); + expect(snap.providerCount).toBe(30); + expect(snap.tokens).toHaveLength(10); + }); }); }); diff --git a/packages/core/src/di/container.ts b/packages/core/src/di/container.ts index a93a2c9..27de8ef 100644 --- a/packages/core/src/di/container.ts +++ b/packages/core/src/di/container.ts @@ -1,5 +1,24 @@ +import { DuplicateProviderError, ProviderNotFoundError } from '../errors'; import { Token } from '../types'; +export type ProviderScope = 'singleton' | 'transient'; + +export interface RegisterOptions { + scope?: ProviderScope; + allowOverride?: boolean; +} + +export interface ProviderMetadata { + lastRegisteredAt: Date; + scope: ProviderScope; + overrides: number; +} + +export interface ContainerSnapshot { + providerCount: number; + tokens: string[]; +} + /** * Simple dependency injection container that holds provider instances by token. * It allows registering and resolving values or class instances during application runtime. @@ -9,6 +28,7 @@ import { Token } from '../types'; */ export class Container { private readonly providers = new Map(); + private readonly metadata = new Map(); /** * Registers a provider instance under the given token. @@ -16,12 +36,27 @@ export class Container { * * @param token The injection token (class constructor, string, or symbol). * @param value The instance or value to associate with the token. + * + * @throws {DuplicateProviderError} If the token already exists and allowOverride is false. */ - public register(token: Token, value: T): void { - if (this.providers.has(token)) { - console.warn(`A provider with the token "${String(token)}" is already registered. It will be overridden.`); + public register(token: Token, value: T, options: RegisterOptions = {}): void { + const { allowOverride = true } = options; + const existed = this.providers.has(token); + + if (existed && !allowOverride) { + throw new DuplicateProviderError(this.tokenToString(token)); } + this.providers.set(token, value); + + const prev = this.metadata.get(token); + const scope: ProviderScope = options.scope ?? prev?.scope ?? 'singleton'; + + this.metadata.set(token, { + lastRegisteredAt: new Date(), + scope, + overrides: (prev?.overrides ?? 0) + (existed ? 1 : 0), + }); } /** @@ -29,16 +64,31 @@ export class Container { * * @param token The injection token to resolve. * @returns The instance or value stored under the token. - * @throws {Error} If no provider is found for the token. + * + * @throws {ProviderNotFoundError} if no provider is found */ public resolve(token: Token): T { - const instance = this.providers.get(token); - if (instance === undefined) { - throw new Error( - `No provider found for token "${String(token)}". Make sure it is provided in a module and exported if necessary.`, - ); + if (!this.providers.has(token)) { + throw new ProviderNotFoundError(this.tokenToString(token), this.getAvailableTokens()); + } + + return this.providers.get(token) as T; + } + + /** + * Safely resolves a provider, returning undefined if not found. + * + * Useful when a dependency is optional or when you want to avoid throwing. + * + * @param token The injection token to resolve. + * @returns The stored value, or undefined if the token is not registered. + */ + public tryResolve(token: Token): T | undefined { + if (!this.providers.has(token)) { + return undefined; } - return instance as T; + + return this.providers.get(token) as T; } /** @@ -51,6 +101,16 @@ export class Container { return this.providers.has(token); } + /** + * Returns metadata about a registered provider. + * + * @param token The token to read metadata for. + * @returns The provider metadata, or undefined if the token is not registered. + */ + public getMetadata(token: Token): ProviderMetadata | undefined { + return this.metadata.get(token); + } + /** * Returns an iterator over all registered provider instances. * Can be used for debugging or lifecycle management. @@ -60,4 +120,82 @@ export class Container { public getInstances(): IterableIterator { return this.providers.values(); } + + /** + * Returns an iterator over all registered tokens. + * + * @returns Iterable iterator of all stored tokens. + */ + public getTokens(): IterableIterator { + return this.providers.keys(); + } + + /** + * Removes a provider (and its metadata) by token. + * + * @param token The token to remove. + * @returns true if the provider existed and was removed, false otherwise. + */ + public remove(token: Token): boolean { + const existed = this.providers.has(token); + + this.providers.delete(token); + this.metadata.delete(token); + + return existed; + } + + /** + * Clears all providers and metadata from the container. + * + * Primarily useful in tests, or when rebuilding an application context. + */ + public clear(): void { + this.providers.clear(); + this.metadata.clear(); + } + + /** + * Returns a lightweight snapshot of the container state for debugging/logging. + * + * @param maxTokens Maximum number of tokens to include in the snapshot for readability. + * @returns A snapshot containing providerCount and a stringified token list. + */ + public snapshot(maxTokens = 50): ContainerSnapshot { + const tokens = Array.from(this.providers.keys()) + .slice(0, maxTokens) + .map((t) => this.tokenToString(t)); + + return { + providerCount: this.providers.size, + tokens, + }; + } + + /** + * Converts an injection token to a readable string for errors/logs. + */ + private tokenToString(token: Token): string { + if (typeof token === 'function') { + return token.name; + } + + if (typeof token === 'symbol') { + return token.toString(); + } + + return String(token); + } + + /** + * Returns a friendly list of available tokens (stringified) for error messages. + * Truncated to 10 tokens for readability. + */ + private getAvailableTokens(): string { + const tokens = Array.from(this.providers.keys()) + .map((t) => this.tokenToString(t)) + .slice(0, 10); + + return tokens.length > 0 ? tokens.join(', ') + (this.providers.size > 10 ? ', ...' : '') : 'none'; + } } diff --git a/packages/core/src/errors/index.ts b/packages/core/src/errors/index.ts new file mode 100644 index 0000000..fd6aac1 --- /dev/null +++ b/packages/core/src/errors/index.ts @@ -0,0 +1,32 @@ +export class AuroraError extends Error { + constructor( + message: string, + public readonly code: string, + public override readonly cause?: Error, + ) { + super(message); + this.name = this.constructor.name; + + const captureStackTrace = ( + Error as unknown as { + captureStackTrace?: (targetObject: object, constructorOpt?: Function) => void; + } + ).captureStackTrace; + + if (typeof captureStackTrace === 'function') { + captureStackTrace(this, this.constructor); + } + } +} + +export class DuplicateProviderError extends AuroraError { + constructor(token: string) { + super(`Provider "${token}" is already registered`, 'DUPLICATE_PROVIDER'); + } +} + +export class ProviderNotFoundError extends AuroraError { + constructor(token: string, availableTokens: string) { + super(`Provider "${token}" not found. Available: ${availableTokens}`, 'PROVIDER_NOT_FOUND'); + } +} From 35a9b12821847cbfb65b9e1ea7085b86e9d2ae4d Mon Sep 17 00:00:00 2001 From: NeekoGta Date: Fri, 9 Jan 2026 16:04:03 +0100 Subject: [PATCH 3/3] fix(core): avoid false circular dependency detection in dependency resolver --- .../core/src/bootstrap/application.factory.ts | 128 ++++++++++-------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/packages/core/src/bootstrap/application.factory.ts b/packages/core/src/bootstrap/application.factory.ts index 3ef8166..880925b 100644 --- a/packages/core/src/bootstrap/application.factory.ts +++ b/packages/core/src/bootstrap/application.factory.ts @@ -194,7 +194,6 @@ export class ApplicationFactory { await this.initializeCoreServices(rootModule); // Plugin lifecycle - console.dir(this.plugins); for (const plugin of this.plugins) { if (plugin.onInit) { await plugin.onInit(this.applicationRef); @@ -341,23 +340,28 @@ export class ApplicationFactory { * @param contextModule The module wrapper providing the DI context. * @returns A Promise resolving to a new instance with all dependencies injected. */ - private async instantiateClass(targetClass: Type, contextModule: ModuleWrapper): Promise { + private async instantiateClass( + targetClass: Type, + contextModule: ModuleWrapper, + seen: Set, + ): Promise { // Resolve constructor-parameter dependencies const paramTypes: (Type | undefined)[] = Reflect.getMetadata('design:paramtypes', targetClass) || []; const customTokens: (Token | undefined)[] = Reflect.getOwnMetadata(INJECT_TOKEN_KEY, targetClass) || []; - const dependencies = await Promise.all( - paramTypes.map(async (paramType, index) => { - // Use custom token if provided, else fall back to the reflected type - const token = customTokens[index] || paramType; - if (!token) { - throw new Error( - `[Aurora] Could not resolve dependency for ${targetClass.name} at constructor index ${index}.`, - ); - } - return this.resolveDependency(token, contextModule); - }), - ); + const dependencies: unknown[] = []; + for (let index = 0; index < paramTypes.length; index++) { + const paramType = paramTypes[index]; + const token = customTokens[index] || paramType; + + if (!token) { + throw new Error( + `[Aurora] Could not resolve dependency for ${targetClass.name} at constructor index ${index}.`, + ); + } + + dependencies.push(await this.resolveDependency(token, contextModule, seen)); + } // Instantiate the class with resolved constructor args const instance = new (targetClass as any)(...dependencies); @@ -374,12 +378,13 @@ export class ApplicationFactory { if (ownProps) { propsToInject.push(...ownProps); } + ctor = Object.getPrototypeOf(ctor); } // Resolve and assign each property dependency for (const { key, token } of propsToInject) { - (instance as any)[key] = await this.resolveDependency(token, contextModule); + (instance as any)[key] = await this.resolveDependency(token, contextModule, seen); } // Wrap with method-level guard proxy @@ -408,58 +413,67 @@ export class ApplicationFactory { if (seen.has(token)) { throw new Error(`[Aurora] Circular dependency detected for token "${getTokenName(token)}".`); } + seen.add(token); - const destinationModule = this.findModuleByProvider(token, contextModule); - if (!destinationModule) { - throw new Error(`[AuroraDI] Cannot resolve dependency for token "${getTokenName(token)}"`); - } - const providerDef = this.findProviderDefinition(token, destinationModule); - if (!providerDef) { - throw new Error(`[AuroraDI] Cannot resolve dependency for token "${getTokenName(token)}"`); - } + try { + const destinationModule = this.findModuleByProvider(token, contextModule); + if (!destinationModule) { + throw new Error(`[AuroraDI] Cannot resolve dependency for token "${getTokenName(token)}"`); + } + + const providerDef = this.findProviderDefinition(token, destinationModule); + if (!providerDef) { + throw new Error(`[AuroraDI] Cannot resolve dependency for token "${getTokenName(token)}"`); + } - const normalized = normalizeProvider(providerDef); - const scope = getProviderScope(providerDef); + const normalized = normalizeProvider(providerDef); + const scope = getProviderScope(providerDef); - // useValue - if ('useValue' in normalized && normalized.useValue !== undefined) { - this.instanceContainer.register(token, normalized.useValue); - return normalized.useValue; - } + // useValue + if ('useValue' in normalized) { + this.instanceContainer.register(token, normalized.useValue); + return normalized.useValue; + } - // useFactory - if ('useFactory' in normalized && normalized.useFactory) { - const args = await Promise.all( - (normalized.inject ?? []).map(async ({ token: inj, optional }) => { - try { - return await this.resolveDependency(inj, destinationModule, seen); - } catch (err) { - if (optional) { - return undefined; + // useFactory + if ('useFactory' in normalized && normalized.useFactory) { + const args = await Promise.all( + (normalized.inject ?? []).map(async ({ token: inj, optional }) => { + try { + return await this.resolveDependency(inj, destinationModule, seen); + } catch (err) { + if (optional) { + return undefined; + } + throw err; } - throw err; - } - }), - ); - const result = await normalized.useFactory(...args); - if (scope === Scope.SINGLETON) { - this.instanceContainer.register(token, result); + }), + ); + + const result = await normalized.useFactory(...args); + if (scope === Scope.SINGLETON) { + this.instanceContainer.register(token, result); + } + + return result; } - return result; - } - // useClass - if ('useClass' in normalized && normalized.useClass) { - const instance = await this.instantiateClass(normalized.useClass, destinationModule); - if (scope === Scope.SINGLETON) { - this.instanceContainer.register(token, instance); + // useClass + if ('useClass' in normalized && normalized.useClass) { + const instance = await this.instantiateClass(normalized.useClass, destinationModule, seen); + if (scope === Scope.SINGLETON) { + this.instanceContainer.register(token, instance); + } + return instance; } - return instance; - } - // Should not happen - throw new Error(`[AuroraDI] Cannot resolve dependency for token "${getTokenName(token)}"`); + // Should not happen + throw new Error(`[AuroraDI] Cannot resolve dependency for token "${getTokenName(token)}"`); + } finally { + // Pop from stack to avoid false positives for transient + shared deps. + seen.delete(token); + } } /**