Skip to content

Commit 3bbfbf5

Browse files
author
Lasim
committed
feat(backend): implement global event bus for plugin communication
1 parent 538b258 commit 3bbfbf5

File tree

8 files changed

+926
-0
lines changed

8 files changed

+926
-0
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* DeployStack Global Event Bus
3+
*
4+
* Core event management system built on Node.js EventEmitter.
5+
* Provides type-safe event emission and plugin listener management.
6+
*/
7+
8+
import { EventEmitter } from 'events';
9+
import { type FastifyBaseLogger } from 'fastify';
10+
import { type EventName, type EventData, type EventContext, type EventHandler } from './types';
11+
12+
/**
13+
* DeployStack Event Bus - Central event management system
14+
*
15+
* Features:
16+
* - Type-safe event emission with compile-time validation
17+
* - Plugin listener registration and cleanup
18+
* - Error isolation (failed listeners don't affect others)
19+
* - Memory efficient (fire-and-forget processing)
20+
* - Audit trail for plugin listener registrations
21+
*/
22+
export class DeployStackEventBus extends EventEmitter {
23+
private logger?: FastifyBaseLogger;
24+
private pluginListeners: Map<string, Set<string>> = new Map();
25+
26+
constructor(logger?: FastifyBaseLogger) {
27+
super();
28+
this.logger = logger;
29+
30+
// Support many plugins listening to events
31+
this.setMaxListeners(100);
32+
33+
// Prevent crashes from unhandled errors in event listeners
34+
this.on('error', (error) => {
35+
this.logger?.error('EventBus error:', error);
36+
});
37+
}
38+
39+
/**
40+
* Emit an event with typed data and context
41+
*
42+
* @param eventName - Type-safe event name constant
43+
* @param data - Strongly-typed event data
44+
* @param context - Event context with database, logger, user info
45+
* @returns true if event had listeners, false otherwise
46+
*/
47+
emitWithContext<T extends EventName>(
48+
eventName: T,
49+
data: EventData<T>,
50+
context: EventContext
51+
): boolean {
52+
try {
53+
this.logger?.debug(`Emitting event: ${eventName}`);
54+
55+
// Emit the event with data and context
56+
const hasListeners = this.emit(eventName, data, context);
57+
58+
if (!hasListeners) {
59+
this.logger?.debug(`No listeners for event: ${eventName}`);
60+
}
61+
62+
return hasListeners;
63+
} catch (error) {
64+
this.logger?.error(`Failed to emit event ${eventName}: ${error}`);
65+
// Don't throw - event emission failures should not break core operations
66+
return false;
67+
}
68+
}
69+
70+
/**
71+
* Register a plugin event listener
72+
*
73+
* @param pluginId - Unique plugin identifier
74+
* @param eventName - Event name to listen for
75+
* @param handler - Event handler function
76+
*/
77+
registerPluginListener<T extends EventName>(
78+
pluginId: string,
79+
eventName: T,
80+
handler: EventHandler<T>
81+
): void {
82+
try {
83+
// Track plugin listeners for cleanup
84+
if (!this.pluginListeners.has(pluginId)) {
85+
this.pluginListeners.set(pluginId, new Set());
86+
}
87+
this.pluginListeners.get(pluginId)!.add(eventName);
88+
89+
// Wrap handler with error isolation
90+
const wrappedHandler = async (data: EventData<T>, context: EventContext) => {
91+
try {
92+
await handler(data, context);
93+
} catch (error) {
94+
this.logger?.error(`Plugin ${pluginId} event handler failed for ${eventName}: ${error}`);
95+
// Don't re-throw - plugin failures should not affect other plugins or core operations
96+
}
97+
};
98+
99+
// Register the wrapped listener
100+
this.on(eventName, wrappedHandler);
101+
102+
this.logger?.info(`Plugin ${pluginId} registered for event: ${eventName}`);
103+
} catch (error) {
104+
this.logger?.error(`Failed to register plugin listener for ${pluginId}: ${error}`);
105+
throw error; // This is a setup error, should be thrown
106+
}
107+
}
108+
109+
/**
110+
* Unregister all event listeners for a plugin
111+
*
112+
* @param pluginId - Plugin identifier to clean up
113+
*/
114+
unregisterPlugin(pluginId: string): void {
115+
try {
116+
const events = this.pluginListeners.get(pluginId);
117+
if (!events) {
118+
this.logger?.debug(`No listeners found for plugin: ${pluginId}`);
119+
return;
120+
}
121+
122+
let removedCount = 0;
123+
events.forEach(eventName => {
124+
const listenerCount = this.listenerCount(eventName);
125+
this.removeAllListeners(eventName);
126+
removedCount += listenerCount;
127+
});
128+
129+
this.pluginListeners.delete(pluginId);
130+
131+
this.logger?.info(`Unregistered ${removedCount} listeners for plugin: ${pluginId}`);
132+
} catch (error) {
133+
this.logger?.error(`Failed to unregister plugin ${pluginId}: ${error}`);
134+
// Don't throw - cleanup failures should not break plugin unloading
135+
}
136+
}
137+
138+
/**
139+
* Get statistics about registered listeners
140+
*
141+
* @returns Event bus statistics
142+
*/
143+
getStats(): {
144+
totalPlugins: number;
145+
totalListeners: number;
146+
eventCounts: Record<string, number>;
147+
} {
148+
const eventCounts: Record<string, number> = {};
149+
150+
// Get listener counts for each event
151+
this.eventNames().forEach(eventName => {
152+
if (typeof eventName === 'string') {
153+
eventCounts[eventName] = this.listenerCount(eventName);
154+
}
155+
});
156+
157+
return {
158+
totalPlugins: this.pluginListeners.size,
159+
totalListeners: Object.values(eventCounts).reduce((sum, count) => sum + count, 0),
160+
eventCounts
161+
};
162+
}
163+
164+
/**
165+
* Check if a plugin has registered listeners
166+
*
167+
* @param pluginId - Plugin identifier to check
168+
* @returns true if plugin has registered listeners
169+
*/
170+
hasPluginListeners(pluginId: string): boolean {
171+
return this.pluginListeners.has(pluginId);
172+
}
173+
174+
/**
175+
* Get list of events a plugin is listening to
176+
*
177+
* @param pluginId - Plugin identifier
178+
* @returns Set of event names the plugin is listening to
179+
*/
180+
getPluginEvents(pluginId: string): Set<string> {
181+
return this.pluginListeners.get(pluginId) || new Set();
182+
}
183+
184+
/**
185+
* Shutdown the event bus and clean up all listeners
186+
*/
187+
shutdown(): void {
188+
try {
189+
this.logger?.info('Shutting down EventBus...');
190+
191+
// Remove all listeners
192+
this.removeAllListeners();
193+
194+
// Clear plugin tracking
195+
this.pluginListeners.clear();
196+
197+
this.logger?.info('EventBus shutdown complete');
198+
} catch (error) {
199+
this.logger?.error(`Error during EventBus shutdown: ${error}`);
200+
}
201+
}
202+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Event name constants for the DeployStack Global Event Bus
3+
*
4+
* Event names are aligned with the existing permission structure:
5+
* - users.* permissions → user.* events
6+
* - teams.* permissions → team.* events
7+
* - settings.* permissions → settings.* events
8+
* - mcp.* permissions → mcp.* events
9+
*/
10+
11+
export const EVENT_NAMES = {
12+
// User Events (aligned with users.* permissions)
13+
USER_REGISTERED: 'user.registered',
14+
USER_LOGIN: 'user.login',
15+
USER_LOGOUT: 'user.logout',
16+
USER_CREATED: 'user.created', // matches users.create permission
17+
USER_UPDATED: 'user.updated', // matches users.edit permission
18+
USER_DELETED: 'user.deleted', // matches users.delete permission
19+
USER_PASSWORD_RESET: 'user.password_reset',
20+
USER_EMAIL_VERIFIED: 'user.email_verified',
21+
22+
// Team Events (aligned with teams.* permissions)
23+
TEAM_CREATED: 'team.created', // matches teams.create permission
24+
TEAM_UPDATED: 'team.updated', // matches teams.edit permission
25+
TEAM_DELETED: 'team.deleted', // matches teams.delete permission
26+
TEAM_MEMBER_ADDED: 'team.member_added',
27+
TEAM_MEMBER_REMOVED: 'team.member_removed',
28+
29+
// Settings Events (aligned with settings.* permissions)
30+
SETTINGS_UPDATED: 'settings.updated', // matches settings.edit permission
31+
SETTINGS_DELETED: 'settings.deleted', // matches settings.delete permission
32+
SETTINGS_GROUP_CREATED: 'settings.group_created',
33+
34+
// MCP Events (aligned with mcp.* permissions)
35+
MCP_INSTALLATION_CREATED: 'mcp.installation_created', // matches mcp.installations.create
36+
MCP_INSTALLATION_UPDATED: 'mcp.installation_updated', // matches mcp.installations.edit
37+
MCP_INSTALLATION_DELETED: 'mcp.installation_deleted', // matches mcp.installations.delete
38+
39+
// System Events
40+
SYSTEM_STARTUP: 'system.startup',
41+
SYSTEM_SHUTDOWN: 'system.shutdown',
42+
SYSTEM_ERROR: 'system.error',
43+
} as const;
44+
45+
// Type-safe event names
46+
export type EventName = typeof EVENT_NAMES[keyof typeof EVENT_NAMES];
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* DeployStack Global Event Bus - Module Exports
3+
*
4+
* This file provides a clean API for consuming the event system.
5+
* Import everything you need from this single entry point.
6+
*/
7+
8+
// Core EventBus class
9+
export { DeployStackEventBus } from './eventBus';
10+
11+
// Event name constants
12+
export { EVENT_NAMES } from './eventNames';
13+
import { EVENT_NAMES } from './eventNames';
14+
15+
// Type definitions
16+
export type {
17+
EventName,
18+
EventContext,
19+
EventData,
20+
EventHandler,
21+
GenericEventHandler,
22+
EventListeners,
23+
CoreEventData
24+
} from './types';
25+
26+
// Re-export specific event names for convenience
27+
export const {
28+
// User Events
29+
USER_REGISTERED,
30+
USER_LOGIN,
31+
USER_LOGOUT,
32+
USER_CREATED,
33+
USER_UPDATED,
34+
USER_DELETED,
35+
USER_PASSWORD_RESET,
36+
USER_EMAIL_VERIFIED,
37+
38+
// Team Events
39+
TEAM_CREATED,
40+
TEAM_UPDATED,
41+
TEAM_DELETED,
42+
TEAM_MEMBER_ADDED,
43+
TEAM_MEMBER_REMOVED,
44+
45+
// Settings Events
46+
SETTINGS_UPDATED,
47+
SETTINGS_DELETED,
48+
SETTINGS_GROUP_CREATED,
49+
50+
// MCP Events
51+
MCP_INSTALLATION_CREATED,
52+
MCP_INSTALLATION_UPDATED,
53+
MCP_INSTALLATION_DELETED,
54+
55+
// System Events
56+
SYSTEM_STARTUP,
57+
SYSTEM_SHUTDOWN,
58+
SYSTEM_ERROR
59+
} = EVENT_NAMES;

0 commit comments

Comments
 (0)