diff --git a/src/dynamic-bridge-discovery/bridge-adapter.interface.ts b/src/dynamic-bridge-discovery/bridge-adapter.interface.ts new file mode 100644 index 0000000..76c0bf6 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge-adapter.interface.ts @@ -0,0 +1,20 @@ +export interface BridgeCapability { + name: string; + version: string; + description?: string; +} + +export interface BridgeAdapter { + readonly name: string; + readonly version: string; + readonly capabilities: BridgeCapability[]; + + initialize(config?: Record): Promise; + isHealthy(): Promise; + shutdown(): Promise; + execute(operation: string, payload: T): Promise; +} + +export interface BridgeAdapterConstructor { + new (config?: Record): BridgeAdapter; +} diff --git a/src/dynamic-bridge-discovery/bridge-config.interface.ts b/src/dynamic-bridge-discovery/bridge-config.interface.ts new file mode 100644 index 0000000..8e4c616 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge-config.interface.ts @@ -0,0 +1,44 @@ +export interface BridgeModuleConfig { + /** + * Directory to scan for bridge adapters at runtime. + * Relative to process.cwd() or absolute path. + */ + bridgesDirectory?: string; + + /** + * Explicitly listed bridges to load (name -> config). + */ + bridges?: Record; + + /** + * Whether to enable auto-discovery from directory + */ + autoDiscover?: boolean; + + /** + * Whether to allow duplicate registrations (overwrite mode) + */ + allowOverwrite?: boolean; + + /** + * Global configuration passed to every bridge on initialization + */ + globalConfig?: Record; +} + +export interface BridgeAdapterConfig { + /** + * Path to the adapter module (for dynamic loading) + */ + modulePath?: string; + + /** + * Enabled/disabled toggle + */ + enabled?: boolean; + + /** + * Adapter-specific configuration + */ + options?: Record; +} diff --git a/src/dynamic-bridge-discovery/bridge-loader.spec.ts b/src/dynamic-bridge-discovery/bridge-loader.spec.ts new file mode 100644 index 0000000..0734442 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge-loader.spec.ts @@ -0,0 +1,268 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BridgeLoader } from '../loaders/bridge.loader'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BridgeAdapter } from '../interfaces/bridge-adapter.interface'; +import { + BridgeInitializationException, + BridgeLoadException, +} from '../exceptions/bridge.exceptions'; + +// ─── Mock helpers ───────────────────────────────────────────────────────────── + +jest.mock('fs'); +const mockFs = fs as jest.Mocked; + +function makeAdapter(name = 'mock-bridge'): BridgeAdapter { + return { + name, + version: '1.0.0', + capabilities: [], + initialize: jest.fn().mockResolvedValue(undefined), + isHealthy: jest.fn().mockResolvedValue(true), + shutdown: jest.fn().mockResolvedValue(undefined), + execute: jest.fn().mockResolvedValue({}), + }; +} + +class ValidAdapter implements BridgeAdapter { + readonly name = 'valid-adapter'; + readonly version = '1.0.0'; + readonly capabilities = []; + + constructor(public readonly config: Record = {}) {} + + async initialize(): Promise {} + async isHealthy(): Promise { return true; } + async shutdown(): Promise {} + async execute(_op: string, _payload: T): Promise { return {} as R; } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('BridgeLoader', () => { + let loader: BridgeLoader; + let registry: BridgeRegistry; + + function buildLoader(config = {}): BridgeLoader { + return new BridgeLoader(registry, config); + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BridgeRegistry], + }).compile(); + + registry = module.get(BridgeRegistry); + loader = buildLoader(); + }); + + afterEach(() => { + registry.clear(); + jest.restoreAllMocks(); + }); + + // ── loadFromDirectory ─────────────────────────────────────────────────────── + + describe('loadFromDirectory()', () => { + it('should skip when directory does not exist', async () => { + mockFs.existsSync.mockReturnValue(false); + const spy = jest.spyOn(registry, 'register'); + + await loader.loadFromDirectory('/non/existent'); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should scan directory and load .adapter.js files', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue(['http.adapter.js', 'ws.adapter.js', 'README.md'] as any); + + const spyLoad = jest + .spyOn(loader, 'loadAdapterFromFile') + .mockResolvedValue(makeAdapter('http-bridge')); + + await loader.loadFromDirectory('/some/bridges'); + + // Only .adapter.js files should be loaded + expect(spyLoad).toHaveBeenCalledTimes(2); + }); + + it('should skip .spec.ts files', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue(['http.adapter.spec.ts', 'ws.adapter.ts'] as any); + + const spyLoad = jest + .spyOn(loader, 'loadAdapterFromFile') + .mockResolvedValue(makeAdapter()); + + await loader.loadFromDirectory('/some/bridges'); + + expect(spyLoad).toHaveBeenCalledTimes(1); + }); + }); + + // ── loadAdapterFromFile ───────────────────────────────────────────────────── + + describe('loadAdapterFromFile()', () => { + it('should load and register a valid adapter from file', async () => { + jest.spyOn(loader as any, 'extractAdapterClass').mockReturnValue(ValidAdapter); + jest.spyOn(loader as any, 'initializeAdapter').mockResolvedValue(undefined); + const registerSpy = jest.spyOn(registry, 'register'); + + const result = await loader.loadAdapterFromFile('/path/valid.adapter.js'); + + expect(result).toBeTruthy(); + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + + it('should return null when no valid class found in module', async () => { + jest.spyOn(loader as any, 'extractAdapterClass').mockReturnValue(null); + + // Override require to return empty module + const originalRequire = (loader as any).__proto__.constructor.require; + jest.doMock('/path/empty.adapter.js', () => ({}), { virtual: true }); + + jest.spyOn(loader as any, 'extractAdapterClass').mockReturnValue(null); + jest.spyOn(require, 'call' as any).mockReturnValueOnce({}); + + const result = await (loader as any).extractAdapterClass({}); + expect(result).toBeNull(); + }); + + it('should throw BridgeLoadException when require fails', async () => { + // Simulate require throwing + const faultyPath = '/nonexistent/broken.adapter.js'; + await expect(loader.loadAdapterFromFile(faultyPath)).rejects.toThrow(BridgeLoadException); + }); + }); + + // ── loadFromConfig ────────────────────────────────────────────────────────── + + describe('loadFromConfig()', () => { + it('should skip disabled bridges', async () => { + const spyLoad = jest.spyOn(loader, 'loadAdapterFromFile').mockResolvedValue(makeAdapter()); + + await loader.loadFromConfig({ + 'disabled-bridge': { enabled: false, modulePath: '/some/path.js' }, + }); + + expect(spyLoad).not.toHaveBeenCalled(); + }); + + it('should skip bridges without modulePath', async () => { + const spyRegister = jest.spyOn(registry, 'register'); + + await loader.loadFromConfig({ 'no-path-bridge': {} }); + + expect(spyRegister).not.toHaveBeenCalled(); + }); + + it('should throw BridgeLoadException for invalid module path', async () => { + await expect( + loader.loadFromConfig({ + 'bad-bridge': { modulePath: '/invalid/nonexistent.js', enabled: true }, + }), + ).rejects.toThrow(BridgeLoadException); + }); + }); + + // ── registerAdapter (runtime injection) ─────────────────────────────────── + + describe('registerAdapter()', () => { + it('should initialize and register a pre-built adapter', async () => { + const adapter = makeAdapter('runtime-bridge'); + const registerSpy = jest.spyOn(registry, 'register'); + + await loader.registerAdapter(adapter); + + expect(adapter.initialize).toHaveBeenCalled(); + expect(registerSpy).toHaveBeenCalledWith(adapter, expect.objectContaining({ + source: 'runtime-injection', + })); + }); + + it('should pass options as metadata during runtime injection', async () => { + const adapter = makeAdapter('runtime-bridge'); + const registerSpy = jest.spyOn(registry, 'register'); + + await loader.registerAdapter(adapter, { plugin: 'custom' }); + + expect(registerSpy).toHaveBeenCalledWith( + adapter, + expect.objectContaining({ plugin: 'custom', source: 'runtime-injection' }), + ); + }); + + it('should throw BridgeInitializationException when adapter initialization fails', async () => { + const adapter = makeAdapter('fail-bridge'); + (adapter.initialize as jest.Mock).mockRejectedValue(new Error('Init failed')); + + await expect(loader.registerAdapter(adapter)).rejects.toThrow(BridgeInitializationException); + }); + }); + + // ── Duck-typing extraction ───────────────────────────────────────────────── + + describe('isAdapterClass() duck typing', () => { + it('should recognize a class with all required methods', () => { + const result = (loader as any).isAdapterClass(ValidAdapter); + expect(result).toBe(true); + }); + + it('should reject plain objects', () => { + expect((loader as any).isAdapterClass({})).toBe(false); + }); + + it('should reject functions missing bridge methods', () => { + class NotAnAdapter { + hello() {} + } + expect((loader as any).isAdapterClass(NotAnAdapter)).toBe(false); + }); + }); + + // ── onModuleInit ─────────────────────────────────────────────────────────── + + describe('onModuleInit()', () => { + it('should skip directory loading when autoDiscover is false', async () => { + const localLoader = buildLoader({ autoDiscover: false, bridgesDirectory: '/some/dir' }); + const spy = jest.spyOn(localLoader, 'loadFromDirectory').mockResolvedValue(undefined); + + await localLoader.onModuleInit(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should call loadFromDirectory when autoDiscover is true', async () => { + const localLoader = buildLoader({ autoDiscover: true, bridgesDirectory: '/some/dir' }); + const spy = jest.spyOn(localLoader, 'loadFromDirectory').mockResolvedValue(undefined); + + await localLoader.onModuleInit(); + + expect(spy).toHaveBeenCalledWith('/some/dir'); + }); + + it('should call loadFromConfig when bridges config is provided', async () => { + const bridges = { 'http-bridge': { modulePath: '/path/http.js' } }; + const localLoader = buildLoader({ bridges }); + const spy = jest.spyOn(localLoader, 'loadFromConfig').mockResolvedValue(undefined); + + await localLoader.onModuleInit(); + + expect(spy).toHaveBeenCalledWith(bridges); + }); + + it('should set overwrite mode from config', async () => { + const localLoader = buildLoader({ allowOverwrite: true }); + const spy = jest.spyOn(registry, 'setOverwriteMode'); + jest.spyOn(localLoader, 'loadFromDirectory').mockResolvedValue(undefined); + jest.spyOn(localLoader, 'loadFromConfig').mockResolvedValue(undefined); + + await localLoader.onModuleInit(); + + expect(spy).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/src/dynamic-bridge-discovery/bridge-module.integration.spec.ts b/src/dynamic-bridge-discovery/bridge-module.integration.spec.ts new file mode 100644 index 0000000..c093e5f --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge-module.integration.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BridgeModule } from '../module/bridge.module'; +import { BridgeService } from '../bridge.service'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BridgeLoader } from '../loaders/bridge.loader'; +import { BridgeAdapter } from '../interfaces/bridge-adapter.interface'; + +function makeAdapter(name: string): BridgeAdapter { + return { + name, + version: '1.0.0', + capabilities: [{ name: 'greet', version: '1.0.0' }], + initialize: jest.fn().mockResolvedValue(undefined), + isHealthy: jest.fn().mockResolvedValue(true), + shutdown: jest.fn().mockResolvedValue(undefined), + execute: jest.fn().mockResolvedValue({ hello: name }), + }; +} + +describe('BridgeModule (integration)', () => { + let module: TestingModule; + let service: BridgeService; + let registry: BridgeRegistry; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + BridgeModule.forRoot({ + autoDiscover: false, + allowOverwrite: false, + }), + ], + }).compile(); + + await module.init(); + + service = module.get(BridgeService); + registry = module.get(BridgeRegistry); + }); + + afterEach(async () => { + await module.close(); + }); + + it('should bootstrap BridgeService', () => { + expect(service).toBeDefined(); + }); + + it('should bootstrap BridgeRegistry', () => { + expect(registry).toBeDefined(); + }); + + it('should bootstrap BridgeLoader', () => { + const loader = module.get(BridgeLoader); + expect(loader).toBeDefined(); + }); + + // ── forRoot() scenario ───────────────────────────────────────────────────── + + describe('forRoot() - static config', () => { + it('should start with empty registry', () => { + expect(service.listBridges()).toHaveLength(0); + }); + + it('should allow runtime injection after module init', async () => { + const adapter = makeAdapter('rt-bridge'); + const loader = module.get(BridgeLoader); + jest.spyOn(loader, 'registerAdapter').mockImplementation(async () => { + registry.register(adapter); + }); + + await service.registerBridge(adapter); + + expect(service.hasBridge('rt-bridge')).toBe(true); + }); + }); + + // ── forRootAsync() scenario ──────────────────────────────────────────────── + + describe('forRootAsync() - factory config', () => { + it('should build module with async config factory', async () => { + const asyncModule = await Test.createTestingModule({ + imports: [ + BridgeModule.forRootAsync({ + useFactory: () => ({ + autoDiscover: false, + allowOverwrite: true, + }), + }), + ], + }).compile(); + + await asyncModule.init(); + + const asyncService = asyncModule.get(BridgeService); + expect(asyncService).toBeDefined(); + + await asyncModule.close(); + }); + }); + + // ── Runtime injection scenario ───────────────────────────────────────────── + + describe('runtime bridge injection', () => { + it('should execute operation on a runtime-injected bridge', async () => { + const adapter = makeAdapter('plugin-bridge'); + registry.register(adapter); + + const result = await service.execute('plugin-bridge', 'greet', { name: 'World' }); + + expect(result).toEqual({ hello: 'plugin-bridge' }); + }); + + it('should reflect runtime-injected bridge in health check', async () => { + const adapter = makeAdapter('healthy-plugin'); + registry.register(adapter); + + const health = await service.healthCheck(); + + expect(health['healthy-plugin']).toBe(true); + }); + + it('should allow multiple bridges to be injected independently', async () => { + registry.register(makeAdapter('plugin-a')); + registry.register(makeAdapter('plugin-b')); + registry.register(makeAdapter('plugin-c')); + + expect(service.listBridges()).toHaveLength(3); + }); + + it('should execute operation on all capability-matching bridges', async () => { + registry.register(makeAdapter('greet-1')); + registry.register(makeAdapter('greet-2')); + + const results = await service.executeByCapability('greet', 'hello', {}); + + expect(results).toHaveLength(2); + }); + }); + + // ── Fallback handling ───────────────────────────────────────────────────── + + describe('fallback handling', () => { + it('tryGetBridge() returns undefined for unavailable bridge', () => { + expect(service.tryGetBridge('nonexistent')).toBeUndefined(); + }); + + it('hasBridge() returns false for unregistered bridge', () => { + expect(service.hasBridge('nonexistent')).toBe(false); + }); + + it('execute() throws for unavailable bridge', async () => { + await expect(service.execute('unavailable', 'op', {})).rejects.toThrow( + 'Bridge adapter "unavailable" not found', + ); + }); + }); +}); diff --git a/src/dynamic-bridge-discovery/bridge-registry.spec.ts b/src/dynamic-bridge-discovery/bridge-registry.spec.ts new file mode 100644 index 0000000..89bd556 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge-registry.spec.ts @@ -0,0 +1,238 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BridgeAdapter } from '../interfaces/bridge-adapter.interface'; +import { + BridgeCapabilityNotFoundException, + BridgeDuplicateException, + BridgeNotFoundException, +} from '../exceptions/bridge.exceptions'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function makeAdapter( + name: string, + capabilities: { name: string; version: string }[] = [], +): BridgeAdapter { + return { + name, + version: '1.0.0', + capabilities, + initialize: jest.fn().mockResolvedValue(undefined), + isHealthy: jest.fn().mockResolvedValue(true), + shutdown: jest.fn().mockResolvedValue(undefined), + execute: jest.fn().mockResolvedValue({ ok: true }), + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('BridgeRegistry', () => { + let registry: BridgeRegistry; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BridgeRegistry], + }).compile(); + + registry = module.get(BridgeRegistry); + }); + + afterEach(() => { + registry.clear(); + }); + + // ── Registration ──────────────────────────────────────────────────────── + + describe('register()', () => { + it('should register a bridge adapter successfully', () => { + const adapter = makeAdapter('test-bridge'); + registry.register(adapter); + + expect(registry.has('test-bridge')).toBe(true); + expect(registry.size).toBe(1); + }); + + it('should store metadata alongside the adapter', () => { + const adapter = makeAdapter('meta-bridge'); + registry.register(adapter, { source: '/some/path' }); + + const entries = registry.listEntries(); + expect(entries[0].metadata).toEqual({ source: '/some/path' }); + }); + + it('should set registeredAt timestamp on registration', () => { + const before = new Date(); + const adapter = makeAdapter('ts-bridge'); + registry.register(adapter); + const after = new Date(); + + const entry = registry.listEntries()[0]; + expect(entry.registeredAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(entry.registeredAt.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('should register multiple different adapters', () => { + registry.register(makeAdapter('bridge-a')); + registry.register(makeAdapter('bridge-b')); + registry.register(makeAdapter('bridge-c')); + + expect(registry.size).toBe(3); + expect(registry.list()).toEqual(expect.arrayContaining(['bridge-a', 'bridge-b', 'bridge-c'])); + }); + }); + + // ── Duplicate prevention ───────────────────────────────────────────────── + + describe('duplicate registration', () => { + it('should throw BridgeDuplicateException on duplicate registration (default mode)', () => { + registry.register(makeAdapter('dupe-bridge')); + + expect(() => registry.register(makeAdapter('dupe-bridge'))).toThrow( + BridgeDuplicateException, + ); + }); + + it('should throw error with correct message on duplicate', () => { + registry.register(makeAdapter('dupe-bridge')); + + expect(() => registry.register(makeAdapter('dupe-bridge'))).toThrow( + 'Bridge adapter "dupe-bridge" is already registered.', + ); + }); + + it('should allow overwrite when allowOverwrite mode is set', () => { + registry.setOverwriteMode(true); + + const first = makeAdapter('overwrite-bridge'); + const second = makeAdapter('overwrite-bridge'); + + registry.register(first); + registry.register(second); // should not throw + + expect(registry.get('overwrite-bridge')).toBe(second); + }); + + it('should keep exactly one entry after overwrite', () => { + registry.setOverwriteMode(true); + registry.register(makeAdapter('overwrite-bridge')); + registry.register(makeAdapter('overwrite-bridge')); + + expect(registry.size).toBe(1); + }); + }); + + // ── Retrieval ───────────────────────────────────────────────────────────── + + describe('get()', () => { + it('should return the registered adapter by name', () => { + const adapter = makeAdapter('my-bridge'); + registry.register(adapter); + + expect(registry.get('my-bridge')).toBe(adapter); + }); + + it('should throw BridgeNotFoundException for unknown bridge', () => { + expect(() => registry.get('unknown')).toThrow(BridgeNotFoundException); + }); + + it('should throw with correct message for unknown bridge', () => { + expect(() => registry.get('ghost-bridge')).toThrow( + 'Bridge adapter "ghost-bridge" not found in registry.', + ); + }); + }); + + describe('tryGet()', () => { + it('should return adapter when found', () => { + const adapter = makeAdapter('safe-bridge'); + registry.register(adapter); + + expect(registry.tryGet('safe-bridge')).toBe(adapter); + }); + + it('should return undefined (not throw) when bridge not found', () => { + expect(registry.tryGet('missing')).toBeUndefined(); + }); + }); + + // ── Capability resolution ───────────────────────────────────────────────── + + describe('getByCapability()', () => { + it('should return adapters matching a capability', () => { + const httpAdapter = makeAdapter('http-bridge', [{ name: 'http', version: '1.0.0' }]); + const wsAdapter = makeAdapter('ws-bridge', [{ name: 'websocket', version: '1.0.0' }]); + registry.register(httpAdapter); + registry.register(wsAdapter); + + const result = registry.getByCapability('http'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('http-bridge'); + }); + + it('should return multiple adapters sharing a capability', () => { + const a = makeAdapter('bridge-a', [{ name: 'rest', version: '1.0.0' }]); + const b = makeAdapter('bridge-b', [{ name: 'rest', version: '2.0.0' }]); + registry.register(a); + registry.register(b); + + const result = registry.getByCapability('rest'); + expect(result).toHaveLength(2); + }); + + it('should throw BridgeCapabilityNotFoundException when no match', () => { + registry.register(makeAdapter('bridge-x', [{ name: 'grpc', version: '1.0.0' }])); + + expect(() => registry.getByCapability('graphql')).toThrow( + BridgeCapabilityNotFoundException, + ); + }); + }); + + // ── List & metadata ─────────────────────────────────────────────────────── + + describe('list()', () => { + it('should return empty array when no bridges registered', () => { + expect(registry.list()).toEqual([]); + }); + + it('should return all bridge names', () => { + registry.register(makeAdapter('a')); + registry.register(makeAdapter('b')); + + expect(registry.list()).toEqual(expect.arrayContaining(['a', 'b'])); + }); + }); + + // ── Unregister ───────────────────────────────────────────────────────────── + + describe('unregister()', () => { + it('should remove a registered adapter', () => { + registry.register(makeAdapter('remove-me')); + registry.unregister('remove-me'); + + expect(registry.has('remove-me')).toBe(false); + }); + + it('should return true when successfully unregistered', () => { + registry.register(makeAdapter('remove-me')); + expect(registry.unregister('remove-me')).toBe(true); + }); + + it('should return false when unregistering non-existent bridge', () => { + expect(registry.unregister('phantom')).toBe(false); + }); + }); + + // ── Clear ────────────────────────────────────────────────────────────────── + + describe('clear()', () => { + it('should remove all registered adapters', () => { + registry.register(makeAdapter('x')); + registry.register(makeAdapter('y')); + registry.clear(); + + expect(registry.size).toBe(0); + expect(registry.list()).toEqual([]); + }); + }); +}); diff --git a/src/dynamic-bridge-discovery/bridge-service.spec.ts b/src/dynamic-bridge-discovery/bridge-service.spec.ts new file mode 100644 index 0000000..2689dc1 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge-service.spec.ts @@ -0,0 +1,186 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BridgeService } from '../bridge.service'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BridgeLoader } from '../loaders/bridge.loader'; +import { BridgeAdapter } from '../interfaces/bridge-adapter.interface'; +import { BridgeNotFoundException } from '../exceptions/bridge.exceptions'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeAdapter(name: string, healthy = true): BridgeAdapter { + return { + name, + version: '1.0.0', + capabilities: [{ name: 'test-cap', version: '1.0.0' }], + initialize: jest.fn().mockResolvedValue(undefined), + isHealthy: jest.fn().mockResolvedValue(healthy), + shutdown: jest.fn().mockResolvedValue(undefined), + execute: jest.fn().mockResolvedValue({ result: `${name}-ok` }), + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('BridgeService', () => { + let service: BridgeService; + let registry: BridgeRegistry; + let loader: BridgeLoader; + + beforeEach(async () => { + const mockLoader = { + registerAdapter: jest.fn().mockResolvedValue(undefined), + loadAdapterFromFile: jest.fn().mockResolvedValue(null), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BridgeService, + BridgeRegistry, + { provide: BridgeLoader, useValue: mockLoader }, + ], + }).compile(); + + service = module.get(BridgeService); + registry = module.get(BridgeRegistry); + loader = module.get(BridgeLoader); + }); + + afterEach(() => { + registry.clear(); + jest.clearAllMocks(); + }); + + // ── execute() ──────────────────────────────────────────────────────────────── + + describe('execute()', () => { + it('should execute operation on a registered bridge', async () => { + const adapter = makeAdapter('http-bridge'); + registry.register(adapter); + + const result = await service.execute('http-bridge', 'GET', { url: '/api' }); + + expect(adapter.execute).toHaveBeenCalledWith('GET', { url: '/api' }); + expect(result).toEqual({ result: 'http-bridge-ok' }); + }); + + it('should throw BridgeNotFoundException for unknown bridge', async () => { + await expect(service.execute('ghost', 'GET', {})).rejects.toThrow(BridgeNotFoundException); + }); + }); + + // ── executeByCapability() ───────────────────────────────────────────────── + + describe('executeByCapability()', () => { + it('should execute operation on all bridges with matching capability', async () => { + const a = makeAdapter('bridge-a'); + const b = makeAdapter('bridge-b'); + registry.register(a); + registry.register(b); + + const results = await service.executeByCapability('test-cap', 'ping', {}); + + expect(a.execute).toHaveBeenCalledWith('ping', {}); + expect(b.execute).toHaveBeenCalledWith('ping', {}); + expect(results).toHaveLength(2); + }); + }); + + // ── registerBridge() (runtime injection) ───────────────────────────────── + + describe('registerBridge()', () => { + it('should delegate to loader.registerAdapter', async () => { + const adapter = makeAdapter('runtime-bridge'); + await service.registerBridge(adapter, { plugin: true }); + + expect(loader.registerAdapter).toHaveBeenCalledWith(adapter, { plugin: true }); + }); + }); + + // ── loadBridgeFromFile() ────────────────────────────────────────────────── + + describe('loadBridgeFromFile()', () => { + it('should delegate to loader.loadAdapterFromFile', async () => { + await service.loadBridgeFromFile('/path/to/bridge.js'); + + expect(loader.loadAdapterFromFile).toHaveBeenCalledWith('/path/to/bridge.js'); + }); + }); + + // ── hasBridge / listBridges / getBridge ─────────────────────────────────── + + describe('bridge querying', () => { + beforeEach(() => { + registry.register(makeAdapter('alpha')); + registry.register(makeAdapter('beta')); + }); + + it('hasBridge() should return true for registered bridges', () => { + expect(service.hasBridge('alpha')).toBe(true); + }); + + it('hasBridge() should return false for unregistered bridges', () => { + expect(service.hasBridge('gamma')).toBe(false); + }); + + it('listBridges() should return all bridge names', () => { + expect(service.listBridges()).toEqual(expect.arrayContaining(['alpha', 'beta'])); + }); + + it('getBridge() should return the adapter', () => { + const adapter = service.getBridge('alpha'); + expect(adapter.name).toBe('alpha'); + }); + + it('tryGetBridge() should return undefined for unknown bridge', () => { + expect(service.tryGetBridge('ghost')).toBeUndefined(); + }); + }); + + // ── healthCheck() ───────────────────────────────────────────────────────── + + describe('healthCheck()', () => { + it('should return health status for all bridges', async () => { + registry.register(makeAdapter('healthy-bridge', true)); + registry.register(makeAdapter('sick-bridge', false)); + + const result = await service.healthCheck(); + + expect(result['healthy-bridge']).toBe(true); + expect(result['sick-bridge']).toBe(false); + }); + + it('should mark bridge as false when isHealthy() throws', async () => { + const adapter = makeAdapter('error-bridge'); + (adapter.isHealthy as jest.Mock).mockRejectedValue(new Error('Timeout')); + registry.register(adapter); + + const result = await service.healthCheck(); + + expect(result['error-bridge']).toBe(false); + }); + }); + + // ── shutdownAll() ────────────────────────────────────────────────────────── + + describe('shutdownAll()', () => { + it('should call shutdown on all bridges', async () => { + const a = makeAdapter('a'); + const b = makeAdapter('b'); + registry.register(a); + registry.register(b); + + await service.shutdownAll(); + + expect(a.shutdown).toHaveBeenCalled(); + expect(b.shutdown).toHaveBeenCalled(); + }); + + it('should not throw even if one bridge shutdown fails', async () => { + const a = makeAdapter('a'); + (a.shutdown as jest.Mock).mockRejectedValue(new Error('Shutdown error')); + registry.register(a); + + await expect(service.shutdownAll()).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/dynamic-bridge-discovery/bridge.decorators.ts b/src/dynamic-bridge-discovery/bridge.decorators.ts new file mode 100644 index 0000000..6269b82 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.decorators.ts @@ -0,0 +1,25 @@ +import { Inject } from '@nestjs/common'; +import { BRIDGE_REGISTRY_TOKEN } from '../interfaces/bridge.tokens'; + +/** + * Injects the BridgeRegistry service. + */ +export const InjectBridgeRegistry = () => Inject(BRIDGE_REGISTRY_TOKEN); + +/** + * Metadata key to mark a class as a BridgeAdapter plugin. + */ +export const BRIDGE_ADAPTER_METADATA = 'BRIDGE_ADAPTER_METADATA'; + +/** + * Decorator to mark a class as a discoverable BridgeAdapter. + * + * @example + * @BridgePlugin({ name: 'my-bridge', version: '1.0.0' }) + * export class MyBridgeAdapter implements BridgeAdapter { ... } + */ +export const BridgePlugin = (meta: { name: string; version: string }): ClassDecorator => { + return (target) => { + Reflect.defineMetadata(BRIDGE_ADAPTER_METADATA, meta, target); + }; +}; diff --git a/src/dynamic-bridge-discovery/bridge.exceptions.ts b/src/dynamic-bridge-discovery/bridge.exceptions.ts new file mode 100644 index 0000000..d192862 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.exceptions.ts @@ -0,0 +1,38 @@ +export class BridgeNotFoundException extends Error { + constructor(name: string) { + super(`Bridge adapter "${name}" not found in registry.`); + this.name = 'BridgeNotFoundException'; + } +} + +export class BridgeDuplicateException extends Error { + constructor(name: string) { + super( + `Bridge adapter "${name}" is already registered. Use allowOverwrite: true to override.`, + ); + this.name = 'BridgeDuplicateException'; + } +} + +export class BridgeInitializationException extends Error { + constructor(name: string, cause: Error) { + super(`Bridge adapter "${name}" failed to initialize: ${cause.message}`); + this.name = 'BridgeInitializationException'; + this.cause = cause; + } +} + +export class BridgeLoadException extends Error { + constructor(path: string, cause: Error) { + super(`Failed to load bridge from path "${path}": ${cause.message}`); + this.name = 'BridgeLoadException'; + this.cause = cause; + } +} + +export class BridgeCapabilityNotFoundException extends Error { + constructor(capability: string) { + super(`No bridge adapter found with capability "${capability}".`); + this.name = 'BridgeCapabilityNotFoundException'; + } +} diff --git a/src/dynamic-bridge-discovery/bridge.loader.ts b/src/dynamic-bridge-discovery/bridge.loader.ts new file mode 100644 index 0000000..13e8d47 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.loader.ts @@ -0,0 +1,176 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BridgeAdapter, BridgeAdapterConstructor } from '../interfaces/bridge-adapter.interface'; +import { BridgeModuleConfig } from '../interfaces/bridge-config.interface'; +import { BridgeInitializationException, BridgeLoadException } from '../exceptions/bridge.exceptions'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BRIDGE_ADAPTER_METADATA } from '../decorators/bridge.decorators'; + +@Injectable() +export class BridgeLoader implements OnModuleInit { + private readonly logger = new Logger(BridgeLoader.name); + + constructor( + private readonly registry: BridgeRegistry, + private readonly config: BridgeModuleConfig, + ) {} + + async onModuleInit(): Promise { + this.registry.setOverwriteMode(this.config.allowOverwrite ?? false); + + if (this.config.autoDiscover && this.config.bridgesDirectory) { + await this.loadFromDirectory(this.config.bridgesDirectory); + } + + if (this.config.bridges) { + await this.loadFromConfig(this.config.bridges); + } + } + + /** + * Scan a directory for bridge adapter modules and auto-register them. + */ + async loadFromDirectory(directory: string): Promise { + const resolvedDir = path.isAbsolute(directory) + ? directory + : path.join(process.cwd(), directory); + + if (!fs.existsSync(resolvedDir)) { + this.logger.warn(`Bridge directory not found: "${resolvedDir}". Skipping auto-discovery.`); + return; + } + + const files = fs + .readdirSync(resolvedDir) + .filter((f) => (f.endsWith('.adapter.ts') || f.endsWith('.adapter.js')) && !f.endsWith('.spec.ts')); + + this.logger.log(`Discovered ${files.length} bridge file(s) in "${resolvedDir}"`); + + for (const file of files) { + const filePath = path.join(resolvedDir, file); + await this.loadAdapterFromFile(filePath); + } + } + + /** + * Load a single bridge adapter from a file path. + */ + async loadAdapterFromFile(filePath: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(filePath); + const AdapterClass = this.extractAdapterClass(mod); + + if (!AdapterClass) { + this.logger.warn(`No valid BridgeAdapter export found in "${filePath}". Skipping.`); + return null; + } + + const adapterConfig = this.config.globalConfig ?? {}; + const instance: BridgeAdapter = new AdapterClass(adapterConfig); + + await this.initializeAdapter(instance); + this.registry.register(instance, { source: filePath }); + + return instance; + } catch (err) { + throw new BridgeLoadException(filePath, err as Error); + } + } + + /** + * Load bridges defined in the configuration object. + */ + async loadFromConfig(bridgesConfig: BridgeModuleConfig['bridges']): Promise { + if (!bridgesConfig) return; + + for (const [name, adapterConfig] of Object.entries(bridgesConfig)) { + if (adapterConfig.enabled === false) { + this.logger.log(`Bridge "${name}" is disabled via config. Skipping.`); + continue; + } + + if (!adapterConfig.modulePath) { + this.logger.warn(`Bridge "${name}" has no modulePath. Skipping.`); + continue; + } + + const resolvedPath = path.isAbsolute(adapterConfig.modulePath) + ? adapterConfig.modulePath + : path.join(process.cwd(), adapterConfig.modulePath); + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(resolvedPath); + const AdapterClass = this.extractAdapterClass(mod); + + if (!AdapterClass) { + this.logger.warn(`No valid BridgeAdapter export found for "${name}". Skipping.`); + continue; + } + + const mergedConfig = { ...(this.config.globalConfig ?? {}), ...(adapterConfig.options ?? {}) }; + const instance: BridgeAdapter = new AdapterClass(mergedConfig); + + await this.initializeAdapter(instance); + this.registry.register(instance, { source: resolvedPath, configKey: name }); + } catch (err) { + throw new BridgeLoadException(resolvedPath, err as Error); + } + } + } + + /** + * Programmatically register a pre-instantiated adapter at runtime. + */ + async registerAdapter(adapter: BridgeAdapter, options?: Record): Promise { + await this.initializeAdapter(adapter); + this.registry.register(adapter, { ...options, source: 'runtime-injection' }); + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + private extractAdapterClass(mod: Record): BridgeAdapterConstructor | null { + // Check default export + if (mod.default && this.isAdapterClass(mod.default)) { + return mod.default as BridgeAdapterConstructor; + } + + // Check named exports + for (const key of Object.keys(mod)) { + if (this.isAdapterClass(mod[key])) { + return mod[key] as BridgeAdapterConstructor; + } + } + + return null; + } + + private isAdapterClass(value: unknown): boolean { + if (typeof value !== 'function') return false; + + // Check for @BridgePlugin decorator metadata + if (Reflect.hasMetadata(BRIDGE_ADAPTER_METADATA, value)) return true; + + // Duck-type check: prototype must have required BridgeAdapter methods + const proto = (value as { prototype?: Record }).prototype; + if (!proto) return false; + + return ( + typeof proto['initialize'] === 'function' && + typeof proto['execute'] === 'function' && + typeof proto['isHealthy'] === 'function' && + typeof proto['shutdown'] === 'function' + ); + } + + private async initializeAdapter(adapter: BridgeAdapter): Promise { + try { + await adapter.initialize(this.config.globalConfig); + this.logger.log(`Initialized bridge adapter: "${adapter.name}"`); + } catch (err) { + throw new BridgeInitializationException(adapter.name, err as Error); + } + } +} diff --git a/src/dynamic-bridge-discovery/bridge.module.ts b/src/dynamic-bridge-discovery/bridge.module.ts new file mode 100644 index 0000000..276564a --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.module.ts @@ -0,0 +1,90 @@ +import { + DynamicModule, + FactoryProvider, + Module, + ModuleMetadata, + Provider, + Type, +} from '@nestjs/common'; +import { BridgeModuleConfig } from '../interfaces/bridge-config.interface'; +import { BRIDGE_MODULE_CONFIG } from '../interfaces/bridge.tokens'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BridgeLoader } from '../loaders/bridge.loader'; +import { BridgeService } from '../bridge.service'; + +export interface BridgeModuleAsyncOptions extends Pick { + useFactory: (...args: unknown[]) => Promise | BridgeModuleConfig; + inject?: FactoryProvider['inject']; + extraProviders?: Provider[]; +} + +@Module({}) +export class BridgeModule { + /** + * Register the BridgeModule synchronously with a static config. + */ + static forRoot(config: BridgeModuleConfig = {}): DynamicModule { + return { + module: BridgeModule, + global: true, + providers: [ + { + provide: BRIDGE_MODULE_CONFIG, + useValue: config, + }, + BridgeRegistry, + { + provide: BridgeLoader, + useFactory: (registry: BridgeRegistry) => new BridgeLoader(registry, config), + inject: [BridgeRegistry], + }, + BridgeService, + ], + exports: [BridgeRegistry, BridgeService, BridgeLoader], + }; + } + + /** + * Register the BridgeModule asynchronously (e.g., reading config from ConfigService). + */ + static forRootAsync(options: BridgeModuleAsyncOptions): DynamicModule { + const configProvider: Provider = { + provide: BRIDGE_MODULE_CONFIG, + useFactory: options.useFactory, + inject: options.inject ?? [], + }; + + const loaderProvider: Provider = { + provide: BridgeLoader, + useFactory: (registry: BridgeRegistry, config: BridgeModuleConfig) => + new BridgeLoader(registry, config), + inject: [BridgeRegistry, BRIDGE_MODULE_CONFIG], + }; + + return { + module: BridgeModule, + global: true, + imports: options.imports ?? [], + providers: [ + configProvider, + BridgeRegistry, + loaderProvider, + BridgeService, + ...(options.extraProviders ?? []), + ], + exports: [BridgeRegistry, BridgeService, BridgeLoader], + }; + } + + /** + * Register a feature module that injects additional bridge adapters. + * Use this inside feature modules to register bridges without modifying core. + */ + static forFeature(adapters: Type[]): DynamicModule { + return { + module: BridgeModule, + providers: adapters, + exports: adapters, + }; + } +} diff --git a/src/dynamic-bridge-discovery/bridge.registry.ts b/src/dynamic-bridge-discovery/bridge.registry.ts new file mode 100644 index 0000000..4dd89c7 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.registry.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BridgeAdapter, BridgeCapability } from '../interfaces/bridge-adapter.interface'; +import { + BridgeCapabilityNotFoundException, + BridgeDuplicateException, + BridgeNotFoundException, +} from '../exceptions/bridge.exceptions'; + +export interface BridgeRegistryEntry { + adapter: BridgeAdapter; + registeredAt: Date; + metadata?: Record; +} + +@Injectable() +export class BridgeRegistry { + private readonly logger = new Logger(BridgeRegistry.name); + private readonly adapters = new Map(); + private allowOverwrite: boolean = false; + + setOverwriteMode(allow: boolean): void { + this.allowOverwrite = allow; + } + + /** + * Register a bridge adapter. + * Throws BridgeDuplicateException if already registered and allowOverwrite=false. + */ + register(adapter: BridgeAdapter, metadata?: Record): void { + if (this.adapters.has(adapter.name) && !this.allowOverwrite) { + throw new BridgeDuplicateException(adapter.name); + } + + if (this.adapters.has(adapter.name)) { + this.logger.warn(`Overwriting bridge adapter: "${adapter.name}"`); + } + + this.adapters.set(adapter.name, { + adapter, + registeredAt: new Date(), + metadata, + }); + + this.logger.log(`Registered bridge adapter: "${adapter.name}" v${adapter.version}`); + } + + /** + * Resolve a bridge by name. + * Throws BridgeNotFoundException if not found. + */ + get(name: string): BridgeAdapter { + const entry = this.adapters.get(name); + if (!entry) { + throw new BridgeNotFoundException(name); + } + return entry.adapter; + } + + /** + * Try to resolve a bridge by name without throwing. + */ + tryGet(name: string): BridgeAdapter | undefined { + return this.adapters.get(name)?.adapter; + } + + /** + * Resolve all bridges that have a given capability. + */ + getByCapability(capabilityName: string): BridgeAdapter[] { + const matches = Array.from(this.adapters.values()) + .map((entry) => entry.adapter) + .filter((adapter) => + adapter.capabilities.some((cap: BridgeCapability) => cap.name === capabilityName), + ); + + if (matches.length === 0) { + throw new BridgeCapabilityNotFoundException(capabilityName); + } + + return matches; + } + + /** + * List all registered bridge names. + */ + list(): string[] { + return Array.from(this.adapters.keys()); + } + + /** + * List full registry entries with metadata. + */ + listEntries(): BridgeRegistryEntry[] { + return Array.from(this.adapters.values()); + } + + /** + * Check whether a bridge is registered. + */ + has(name: string): boolean { + return this.adapters.has(name); + } + + /** + * Unregister a bridge by name. + */ + unregister(name: string): boolean { + const removed = this.adapters.delete(name); + if (removed) { + this.logger.log(`Unregistered bridge adapter: "${name}"`); + } + return removed; + } + + /** + * Clear all registered adapters. + */ + clear(): void { + this.adapters.clear(); + this.logger.log('Cleared all bridge adapters from registry'); + } + + /** + * Return count of registered bridges. + */ + get size(): number { + return this.adapters.size; + } +} diff --git a/src/dynamic-bridge-discovery/bridge.service.ts b/src/dynamic-bridge-discovery/bridge.service.ts new file mode 100644 index 0000000..13586f0 --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { BridgeAdapter } from '../interfaces/bridge-adapter.interface'; +import { BridgeRegistry } from '../registry/bridge.registry'; +import { BridgeLoader } from '../loaders/bridge.loader'; + +@Injectable() +export class BridgeService { + constructor( + private readonly registry: BridgeRegistry, + private readonly loader: BridgeLoader, + ) {} + + /** + * Execute an operation on a named bridge. + */ + async execute( + bridgeName: string, + operation: string, + payload: T, + ): Promise { + const adapter = this.registry.get(bridgeName); + return adapter.execute(operation, payload); + } + + /** + * Execute an operation on all bridges with a given capability. + */ + async executeByCapability( + capability: string, + operation: string, + payload: T, + ): Promise { + const adapters = this.registry.getByCapability(capability); + return Promise.all(adapters.map((a) => a.execute(operation, payload))); + } + + /** + * Register a bridge adapter at runtime (plugin injection). + */ + async registerBridge(adapter: BridgeAdapter, options?: Record): Promise { + await this.loader.registerAdapter(adapter, options); + } + + /** + * Load a bridge from a file path at runtime. + */ + async loadBridgeFromFile(filePath: string): Promise { + return this.loader.loadAdapterFromFile(filePath); + } + + /** + * Check if a bridge is available. + */ + hasBridge(name: string): boolean { + return this.registry.has(name); + } + + /** + * List all registered bridge names. + */ + listBridges(): string[] { + return this.registry.list(); + } + + /** + * Get a bridge adapter directly. + */ + getBridge(name: string): BridgeAdapter { + return this.registry.get(name); + } + + /** + * Attempt to get a bridge without throwing. + */ + tryGetBridge(name: string): BridgeAdapter | undefined { + return this.registry.tryGet(name); + } + + /** + * Health check for all registered bridges. + */ + async healthCheck(): Promise> { + const results: Record = {}; + for (const name of this.registry.list()) { + try { + results[name] = await this.registry.get(name).isHealthy(); + } catch { + results[name] = false; + } + } + return results; + } + + /** + * Gracefully shutdown all bridge adapters. + */ + async shutdownAll(): Promise { + for (const name of this.registry.list()) { + try { + await this.registry.get(name).shutdown(); + } catch { + // best-effort shutdown + } + } + } +} diff --git a/src/dynamic-bridge-discovery/bridge.tokens.ts b/src/dynamic-bridge-discovery/bridge.tokens.ts new file mode 100644 index 0000000..6494e2b --- /dev/null +++ b/src/dynamic-bridge-discovery/bridge.tokens.ts @@ -0,0 +1,3 @@ +export const BRIDGE_MODULE_CONFIG = 'BRIDGE_MODULE_CONFIG'; +export const BRIDGE_ADAPTER_TOKEN = 'BRIDGE_ADAPTER'; +export const BRIDGE_REGISTRY_TOKEN = 'BRIDGE_REGISTRY'; diff --git a/src/dynamic-bridge-discovery/http-bridge.adapter.ts b/src/dynamic-bridge-discovery/http-bridge.adapter.ts new file mode 100644 index 0000000..349036b --- /dev/null +++ b/src/dynamic-bridge-discovery/http-bridge.adapter.ts @@ -0,0 +1,38 @@ +import { BridgeAdapter, BridgeCapability } from '../interfaces/bridge-adapter.interface'; +import { BridgePlugin } from '../decorators/bridge.decorators'; + +@BridgePlugin({ name: 'http-bridge', version: '1.0.0' }) +export class HttpBridgeAdapter implements BridgeAdapter { + readonly name = 'http-bridge'; + readonly version = '1.0.0'; + readonly capabilities: BridgeCapability[] = [ + { name: 'http', version: '1.0.0', description: 'HTTP request execution' }, + { name: 'rest', version: '1.0.0', description: 'REST API calls' }, + ]; + + private baseUrl: string = ''; + private initialized = false; + + constructor(private readonly config: Record = {}) {} + + async initialize(config?: Record): Promise { + const merged = { ...this.config, ...(config ?? {}) }; + this.baseUrl = (merged['baseUrl'] as string) ?? 'http://localhost'; + this.initialized = true; + } + + async isHealthy(): Promise { + return this.initialized; + } + + async shutdown(): Promise { + this.initialized = false; + } + + async execute(operation: string, payload: T): Promise { + if (!this.initialized) throw new Error('HttpBridgeAdapter not initialized'); + + // Simulated execution — replace with actual HTTP client logic + return { operation, payload, baseUrl: this.baseUrl, bridge: this.name } as unknown as R; + } +} diff --git a/src/dynamic-bridge-discovery/index.ts b/src/dynamic-bridge-discovery/index.ts new file mode 100644 index 0000000..32b2091 --- /dev/null +++ b/src/dynamic-bridge-discovery/index.ts @@ -0,0 +1,30 @@ +// Module +export { BridgeModule } from './module/bridge.module'; + +// Services +export { BridgeService } from './bridge.service'; +export { BridgeRegistry } from './registry/bridge.registry'; +export { BridgeLoader } from './loaders/bridge.loader'; + +// Interfaces +export { BridgeAdapter, BridgeCapability, BridgeAdapterConstructor } from './interfaces/bridge-adapter.interface'; +export { BridgeModuleConfig, BridgeAdapterConfig } from './interfaces/bridge-config.interface'; + +// Decorators +export { BridgePlugin, InjectBridgeRegistry, BRIDGE_ADAPTER_METADATA } from './decorators/bridge.decorators'; + +// Tokens +export { BRIDGE_MODULE_CONFIG, BRIDGE_ADAPTER_TOKEN, BRIDGE_REGISTRY_TOKEN } from './interfaces/bridge.tokens'; + +// Exceptions +export { + BridgeNotFoundException, + BridgeDuplicateException, + BridgeInitializationException, + BridgeLoadException, + BridgeCapabilityNotFoundException, +} from './exceptions/bridge.exceptions'; + +// Example adapters (not for production use — illustrative only) +export { HttpBridgeAdapter } from './adapters/http-bridge.adapter'; +export { WebSocketBridgeAdapter } from './adapters/websocket-bridge.adapter'; diff --git a/src/dynamic-bridge-discovery/websocket-bridge.adapter.ts b/src/dynamic-bridge-discovery/websocket-bridge.adapter.ts new file mode 100644 index 0000000..0adc3d4 --- /dev/null +++ b/src/dynamic-bridge-discovery/websocket-bridge.adapter.ts @@ -0,0 +1,37 @@ +import { BridgeAdapter, BridgeCapability } from '../interfaces/bridge-adapter.interface'; +import { BridgePlugin } from '../decorators/bridge.decorators'; + +@BridgePlugin({ name: 'ws-bridge', version: '1.0.0' }) +export class WebSocketBridgeAdapter implements BridgeAdapter { + readonly name = 'ws-bridge'; + readonly version = '1.0.0'; + readonly capabilities: BridgeCapability[] = [ + { name: 'websocket', version: '1.0.0', description: 'WebSocket communication' }, + { name: 'realtime', version: '1.0.0', description: 'Real-time event streaming' }, + ]; + + private wsUrl: string = ''; + private initialized = false; + + constructor(private readonly config: Record = {}) {} + + async initialize(config?: Record): Promise { + const merged = { ...this.config, ...(config ?? {}) }; + this.wsUrl = (merged['wsUrl'] as string) ?? 'ws://localhost'; + this.initialized = true; + } + + async isHealthy(): Promise { + return this.initialized; + } + + async shutdown(): Promise { + this.initialized = false; + } + + async execute(operation: string, payload: T): Promise { + if (!this.initialized) throw new Error('WebSocketBridgeAdapter not initialized'); + + return { operation, payload, wsUrl: this.wsUrl, bridge: this.name } as unknown as R; + } +}