├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── pipeline.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── assets ├── eventhub-overview.drawio └── eventhub-overview.png ├── package.json ├── packages └── event-hub │ ├── eslint.config.js │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── channel.spec.ts │ ├── channel.ts │ ├── connector.spec.ts │ ├── connector.ts │ ├── event-hub.spec.ts │ ├── event-hub.ts │ ├── index.ts │ ├── pipeline.spec.ts │ ├── pipeline.ts │ ├── transport.spec.ts │ ├── transport.ts │ └── types.ts │ ├── tsconfig.json │ └── tsconfig.lint.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.{js,ts,jsx,tsx,html,css,json,yml,yaml,md}] 10 | quote_type = single 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | max_line_length = 120 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ### To Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ### Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ### Screenshots 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | ### Desktop (please complete the following information): 31 | 32 | - OS: [e.g. iOS] 33 | - Browser [e.g. chrome, safari] 34 | - Version [e.g. 22] 35 | 36 | ### Smartphone (please complete the following information): 37 | - Device: [e.g. iPhone6] 38 | - OS: [e.g. iOS8.1] 39 | - Browser [e.g. stock browser, safari] 40 | - Version [e.g. 22] 41 | 42 | ### Additional context 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Describe alternatives you've considered 17 | 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | ## Additional context 21 | 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Stage Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | paths-ignore: 8 | - '*.md' 9 | pull_request: 10 | branches: 11 | - "*" 12 | paths-ignore: 13 | - '*.md' 14 | 15 | jobs: 16 | StageBuild: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v4 23 | with: 24 | version: 10 25 | - name: Use Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 22 29 | cache: 'pnpm' 30 | - name: Install dependencies 31 | run: pnpm install 32 | 33 | - name: Run Lint (event-hub) 34 | run: pnpm --filter event-hub run lint 35 | 36 | - name: Build event-hub 37 | run: pnpm --filter event-hub run build 38 | 39 | - name: Run tests (event-hub) 40 | run: pnpm --filter event-hub run test:ci -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # misc 4 | .DS_Store 5 | *.log* 6 | .amazon_q 7 | 8 | # dependencies 9 | **/.yarn/* 10 | !**/.yarn/patches 11 | !**/.yarn/plugins 12 | !**/.yarn/releases 13 | !**/.yarn/sdks 14 | !**/.yarn/versions 15 | **/node_modules 16 | **/.pnp 17 | **/.pnp.* 18 | 19 | # project 20 | .idea 21 | dist 22 | docs 23 | coverage 24 | act 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 120, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Serverless DNA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EventHub For Front Ends (Effe) 2 | 3 |
4 | 5 | [![Pipeline](https://github.com/serverless-dna/effe/actions/workflows/pipeline.yml/badge.svg)](https://github.com/serverless-dna/effe/actions/workflows/pipeline.yml) 6 | 7 |
8 | 9 | ## The Swiss Army Knife for Front-End Event Architecture 10 | 11 | Effe (pronounced "e-ff-y") is a powerful, composable toolkit that connects your frontend JavaScript applications to backend Event Driven Architecture (EDA). It provides a framework-agnostic approach to event handling with a modular design that lets you build exactly what you need. 12 | 13 | ## Core Components 14 | 15 | Effe is built on four foundational components that work together seamlessly while maintaining clear separation of concerns: 16 | 17 | ### 1. EventHub - The Central Nervous System 18 | 19 | The EventHub provides a familiar pub/sub interface for your front-end components: 20 | 21 | - **Channel-based messaging** - Organize your events by logical channels 22 | - **Wildcard subscriptions** - Subscribe to all events with the `*` channel 23 | - **Type-safe events** - Leverage TypeScript for type safety across your event system 24 | - **Asynchronous by design** - Built for modern async/await patterns 25 | 26 | ```typescript 27 | import { EventHub } from '@effedev/effe'; 28 | 29 | // Define type-safe event interfaces 30 | interface UserLoginEvent { 31 | userId: string; 32 | timestamp: number; 33 | sessionId: string; 34 | } 35 | 36 | interface UserLogoutEvent { 37 | userId: string; 38 | timestamp: number; 39 | sessionDuration: number; 40 | } 41 | 42 | // Create an EventHub 43 | const eventHub = new EventHub(); 44 | 45 | // Subscribe to events with type safety 46 | const subscription = eventHub.subscribe('user-login', (event) => { 47 | console.log(`User ${event.userId} logged in at ${new Date(event.timestamp)}`); 48 | // TypeScript knows event has userId, timestamp, and sessionId properties 49 | }); 50 | 51 | // Publish a type-safe event 52 | await eventHub.publish('user-login', { 53 | userId: 'user123', 54 | timestamp: Date.now(), 55 | sessionId: 'sess_abc123' 56 | }); 57 | 58 | // Later, unsubscribe when no longer needed 59 | subscription.unsubscribe(); 60 | ``` 61 | 62 | ### 2. Connectors - The Bridge Builders 63 | 64 | Connectors establish the flow of events between your EventHub and external systems: 65 | 66 | - **SourceConnector** - Brings external events into your EventHub 67 | - **SinkConnector** - Sends EventHub events to external systems 68 | - **Bidirectional flows** - Combine source and sink connectors for two-way communication 69 | - **Channel mapping** - Route external events to specific EventHub channels 70 | 71 | ```typescript 72 | import { EventHub, SourceConnector, SinkConnector, SourceTransport, SinkTransport } from '@effedev/effe'; 73 | 74 | // Define domain-specific types 75 | interface ServerMessage { 76 | type: 'notification' | 'update' | 'error'; 77 | payload: unknown; 78 | timestamp: number; 79 | } 80 | 81 | interface ClientMessage { 82 | action: string; 83 | data: unknown; 84 | requestId: string; 85 | } 86 | 87 | // Example implementation of a WebSocket source connector with type safety 88 | class WebSocketSourceConnector extends SourceConnector { 89 | constructor(eventHub: EventHub, url: string, channel: string) { 90 | // Create a transport for the WebSocket connection 91 | const transport = new WebSocketSourceTransport(url); 92 | super(eventHub, transport, channel); 93 | } 94 | } 95 | 96 | // Example implementation of a WebSocket sink connector with type safety 97 | class WebSocketSinkConnector extends SinkConnector { 98 | constructor(eventHub: EventHub, url: string, channel: string) { 99 | // Create a transport for the WebSocket connection 100 | const transport = new WebSocketSinkTransport(url); 101 | super(eventHub, transport, channel); 102 | } 103 | } 104 | 105 | // Create connectors 106 | const sourceConnector = new WebSocketSourceConnector(eventHub, 'wss://api.example.com', 'server-events'); 107 | const sinkConnector = new WebSocketSinkConnector(eventHub, 'wss://api.example.com', 'client-events'); 108 | 109 | // Connect to start the flow of events 110 | await sourceConnector.connect(); 111 | await sinkConnector.connect(); 112 | ``` 113 | 114 | ### 3. Transports - The Communication Layer 115 | 116 | Transports handle the actual communication with external systems: 117 | 118 | - **SourceTransport** - Receives data from external sources 119 | - **SinkTransport** - Sends data to external destinations 120 | - **Connection management** - Handles connection lifecycle and state 121 | - **Protocol abstraction** - Encapsulates protocol-specific details 122 | 123 | ```typescript 124 | import { SourceTransport, SinkTransport } from '@effedev/effe'; 125 | 126 | // Define domain-specific types 127 | interface ServerMessage { 128 | type: 'notification' | 'update' | 'error'; 129 | payload: unknown; 130 | timestamp: number; 131 | } 132 | 133 | interface ClientMessage { 134 | action: string; 135 | data: unknown; 136 | requestId: string; 137 | } 138 | 139 | // Example implementation of a WebSocket source transport with type safety 140 | class WebSocketSourceTransport extends SourceTransport { 141 | private ws: WebSocket; 142 | 143 | constructor(private url: string) { 144 | super(`ws-source-${url}`); 145 | } 146 | 147 | async connect(): Promise { 148 | this.ws = new WebSocket(this.url); 149 | this._connected = true; 150 | 151 | this.ws.onmessage = async (event) => { 152 | // Process incoming messages through the pipeline if set 153 | await this.messageHandler(event.data); 154 | }; 155 | } 156 | 157 | async disconnect(): Promise { 158 | this.ws.close(); 159 | this._connected = false; 160 | } 161 | } 162 | 163 | // Example implementation of a WebSocket sink transport with type safety 164 | class WebSocketSinkTransport extends SinkTransport { 165 | private ws: WebSocket; 166 | 167 | constructor(private url: string) { 168 | super(`ws-sink-${url}`); 169 | } 170 | 171 | async connect(): Promise { 172 | this.ws = new WebSocket(this.url); 173 | this._connected = true; 174 | } 175 | 176 | async disconnect(): Promise { 177 | this.ws.close(); 178 | this._connected = false; 179 | } 180 | 181 | protected async sendMessage(data: string): Promise { 182 | this.ws.send(data); 183 | } 184 | } 185 | ``` 186 | 187 | ### 4. Pipelines - The Data Transformation Engine 188 | 189 | Pipelines process and transform data as it flows through your system: 190 | 191 | - **Multi-stage processing** - Chain filters for complex transformations 192 | - **Type-safe transformations** - Maintain type safety between pipeline stages 193 | - **Error handling** - Graceful handling of transformation errors 194 | - **Validation** - Validate data at any stage of processing 195 | 196 | ```typescript 197 | import { Pipeline, IPipelineFilter, PipelineResult } from '@effedev/effe'; 198 | 199 | // Define domain-specific types 200 | interface ServerMessage { 201 | type: 'notification' | 'update' | 'error'; 202 | payload: unknown; 203 | timestamp: number; 204 | } 205 | 206 | interface UserNotification { 207 | userId: string; 208 | message: string; 209 | level: 'info' | 'warning' | 'error'; 210 | timestamp: Date; 211 | } 212 | 213 | // Example filter that parses JSON strings into ServerMessage objects 214 | class JsonParserFilter implements IPipelineFilter { 215 | async process(data: string): Promise> { 216 | try { 217 | const parsed = JSON.parse(data); 218 | 219 | // Validate the parsed data has the required structure 220 | if (!parsed.type || !parsed.timestamp) { 221 | return { 222 | success: false, 223 | error: new Error('Invalid message format: missing required fields') 224 | }; 225 | } 226 | 227 | // Convert to ServerMessage type 228 | const message: ServerMessage = { 229 | type: parsed.type, 230 | payload: parsed.payload, 231 | timestamp: parsed.timestamp 232 | }; 233 | 234 | return { success: true, data: message }; 235 | } catch (error) { 236 | return { success: false, error: error as Error }; 237 | } 238 | } 239 | } 240 | 241 | // Example filter that transforms ServerMessage to UserNotification when applicable 242 | class NotificationTransformerFilter implements IPipelineFilter { 243 | async process(data: ServerMessage): Promise> { 244 | try { 245 | // Only transform notification type messages 246 | if (data.type !== 'notification') { 247 | return { success: true, data: null }; 248 | } 249 | 250 | // Ensure payload has the expected structure 251 | const payload = data.payload as any; 252 | if (!payload.userId || !payload.message) { 253 | return { 254 | success: false, 255 | error: new Error('Invalid notification payload') 256 | }; 257 | } 258 | 259 | // Transform to UserNotification 260 | const notification: UserNotification = { 261 | userId: payload.userId, 262 | message: payload.message, 263 | level: payload.level || 'info', 264 | timestamp: new Date(data.timestamp) 265 | }; 266 | 267 | return { success: true, data: notification }; 268 | } catch (error) { 269 | return { success: false, error: error as Error }; 270 | } 271 | } 272 | } 273 | 274 | // Create a pipeline that transforms string data to UserNotification objects 275 | const pipeline = new Pipeline() 276 | .add(new JsonParserFilter()) 277 | .add(new NotificationTransformerFilter()); 278 | 279 | // Use the pipeline in a transport 280 | const transport = new WebSocketSourceTransport('wss://api.example.com'); 281 | transport.usePipeline(pipeline); 282 | ``` 283 | 284 | ## Composable Architecture 285 | 286 | Effe's power comes from its composable design. Mix and match components to create exactly the event architecture you need: 287 | 288 | 1. **EventHub alone** - Use it as a simple pub/sub system within your application 289 | 2. **EventHub + Connectors** - Connect your application to external event sources 290 | 3. **Add Pipelines** - Transform, validate, and enrich data as it flows through your system 291 | 4. **Multiple Connectors** - Connect to multiple backends simultaneously 292 | 5. **Custom Transports** - Implement custom transports for any protocol 293 | 294 | ## Real-World Examples 295 | 296 | ### WebSocket Integration 297 | 298 | ```typescript 299 | import { EventHub, SourceConnector, SinkConnector, Pipeline, IPipelineFilter, PipelineResult } from '@effedev/effe'; 300 | 301 | // Define domain-specific types 302 | interface ChatMessage { 303 | messageId: string; 304 | sender: string; 305 | content: string; 306 | timestamp: number; 307 | room: string; 308 | } 309 | 310 | interface UserPresence { 311 | userId: string; 312 | status: 'online' | 'away' | 'offline'; 313 | lastActive: number; 314 | } 315 | 316 | interface OutgoingMessage { 317 | type: 'chat' | 'presence'; 318 | data: ChatMessage | UserPresence; 319 | } 320 | 321 | // Create custom filters 322 | class JsonParserFilter implements IPipelineFilter { 323 | async process(data: string): Promise> { 324 | try { 325 | return { success: true, data: JSON.parse(data) }; 326 | } catch (error) { 327 | return { success: false, error: error as Error }; 328 | } 329 | } 330 | } 331 | 332 | class MessageTypeRouter implements IPipelineFilter { 333 | async process(data: any): Promise> { 334 | try { 335 | if (data.type === 'chat') { 336 | const chatMessage: ChatMessage = { 337 | messageId: data.id, 338 | sender: data.from, 339 | content: data.text, 340 | timestamp: data.time, 341 | room: data.room 342 | }; 343 | return { success: true, data: chatMessage }; 344 | } else if (data.type === 'presence') { 345 | const presence: UserPresence = { 346 | userId: data.userId, 347 | status: data.status, 348 | lastActive: data.lastActive 349 | }; 350 | return { success: true, data: presence }; 351 | } 352 | return { success: true, data: null }; 353 | } catch (error) { 354 | return { success: false, error: error as Error }; 355 | } 356 | } 357 | } 358 | 359 | // Create custom transports and connectors (implementation details omitted for brevity) 360 | class WebSocketSourceTransport extends SourceTransport { /* ... */ } 361 | class WebSocketSinkTransport extends SinkTransport { /* ... */ } 362 | 363 | class WebSocketSourceConnector extends SourceConnector { /* ... */ } 364 | class WebSocketSinkConnector extends SinkConnector { /* ... */ } 365 | 366 | // Create an EventHub 367 | const eventHub = new EventHub(); 368 | 369 | // Create a bidirectional WebSocket connection 370 | const wsSourceTransport = new WebSocketSourceTransport('wss://chat.example.com'); 371 | const wsSinkTransport = new WebSocketSinkTransport('wss://chat.example.com'); 372 | 373 | const wsSource = new WebSocketSourceConnector(eventHub, wsSourceTransport, 'incoming-messages'); 374 | const wsSink = new WebSocketSinkConnector(eventHub, wsSinkTransport, 'outgoing-messages'); 375 | 376 | // Add data transformation 377 | const inboundPipeline = new Pipeline() 378 | .add(new JsonParserFilter()) 379 | .add(new MessageTypeRouter()); 380 | wsSourceTransport.usePipeline(inboundPipeline); 381 | 382 | // Connect everything 383 | await wsSource.connect(); 384 | await wsSink.connect(); 385 | 386 | // Now your UI can interact with the chat server via EventHub 387 | eventHub.subscribe('incoming-messages', (message) => { 388 | if (message.room === currentRoom) { 389 | displayChatMessage(message); 390 | } 391 | }); 392 | 393 | eventHub.subscribe('incoming-messages', (presence) => { 394 | updateUserStatus(presence); 395 | }); 396 | 397 | // Send a message when the user clicks the send button 398 | sendButton.addEventListener('click', () => { 399 | const message: OutgoingMessage = { 400 | type: 'chat', 401 | data: { 402 | messageId: generateId(), 403 | sender: currentUser, 404 | content: messageInput.value, 405 | timestamp: Date.now(), 406 | room: currentRoom 407 | } 408 | }; 409 | eventHub.publish('outgoing-messages', message); 410 | messageInput.value = ''; 411 | }); 412 | ``` 413 | 414 | ### Multi-Backend Integration 415 | 416 | ```typescript 417 | import { EventHub } from '@effedev/effe'; 418 | 419 | // Define domain-specific types 420 | interface UserActivity { 421 | userId: string; 422 | action: string; 423 | page: string; 424 | timestamp: number; 425 | metadata?: Record; 426 | } 427 | 428 | interface ApiRequest { 429 | endpoint: string; 430 | method: 'GET' | 'POST' | 'PUT' | 'DELETE'; 431 | data?: any; 432 | headers?: Record; 433 | } 434 | 435 | interface NotificationMessage { 436 | userId: string; 437 | type: 'info' | 'warning' | 'error'; 438 | message: string; 439 | timestamp: number; 440 | } 441 | 442 | // Create custom connectors (implementation details omitted for brevity) 443 | class WebSocketConnector { /* ... */ } 444 | class RestApiConnector { /* ... */ } 445 | class AnalyticsConnector { /* ... */ } 446 | 447 | // Create an EventHub 448 | const eventHub = new EventHub(); 449 | 450 | // Connect to multiple backends 451 | const wsConnector = new WebSocketConnector(eventHub, 'wss://realtime.example.com'); 452 | const restConnector = new RestApiConnector(eventHub, 'https://api.example.com'); 453 | const analyticsConnector = new AnalyticsConnector(eventHub, 'user-activity'); 454 | 455 | // Connect everything 456 | await Promise.all([ 457 | wsConnector.connect(), 458 | restConnector.connect(), 459 | analyticsConnector.connect() 460 | ]); 461 | 462 | // Your UI interacts with a single EventHub, but events flow to multiple backends 463 | eventHub.publish('user-activity', { 464 | userId: 'user123', 465 | action: 'page-view', 466 | page: '/dashboard', 467 | timestamp: Date.now(), 468 | metadata: { 469 | referrer: document.referrer, 470 | viewport: `${window.innerWidth}x${window.innerHeight}` 471 | } 472 | }); 473 | 474 | // Send API requests through the EventHub 475 | eventHub.publish('api-requests', { 476 | endpoint: '/users/profile', 477 | method: 'GET', 478 | headers: { 479 | 'Accept': 'application/json' 480 | } 481 | }); 482 | 483 | // Handle notifications from the server 484 | eventHub.subscribe('notifications', (notification) => { 485 | showToast(notification.message, notification.type); 486 | }); 487 | ``` 488 | 489 | ## Why Effe? 490 | 491 | - **Framework agnostic** - Works with React, Vue, Angular, or vanilla JS 492 | - **Lightweight** - Small footprint, no heavy dependencies 493 | - **Extensible** - Easy to extend with custom connectors and transports 494 | - **Type-safe** - Built with TypeScript for robust type checking 495 | - **Testable** - Easy to mock and test each component independently 496 | - **Separation of concerns** - Clean architecture with clear responsibilities 497 | 498 | ## Getting Started 499 | 500 | ```bash 501 | npm install @effedev/effe 502 | ``` 503 | 504 | ```typescript 505 | import { EventHub } from '@effedev/effe'; 506 | 507 | // Define your event types 508 | interface NotificationEvent { 509 | title: string; 510 | message: string; 511 | type: 'info' | 'warning' | 'error'; 512 | } 513 | 514 | interface UserActionEvent { 515 | action: string; 516 | elementId: string; 517 | timestamp: number; 518 | metadata?: Record; 519 | } 520 | 521 | // Create an EventHub 522 | const eventHub = new EventHub(); 523 | 524 | // Subscribe to events with type safety 525 | eventHub.subscribe('notifications', (data) => { 526 | showNotification(data.title, data.message, data.type); 527 | }); 528 | 529 | // Create your custom connector implementation 530 | class WebSocketConnector { 531 | constructor(eventHub, url) { 532 | // Implementation details... 533 | } 534 | 535 | async connect() { 536 | // Connect to WebSocket and set up event flow 537 | } 538 | } 539 | 540 | // Connect to a WebSocket server 541 | const connector = new WebSocketConnector(eventHub, 'wss://api.example.com'); 542 | await connector.connect(); 543 | 544 | // Publish a type-safe event 545 | eventHub.publish('user-action', { 546 | action: 'button-click', 547 | elementId: 'submit-btn', 548 | timestamp: Date.now() 549 | }); 550 | ``` 551 | 552 | ## Connect Your Events to Everything! 553 | 554 | With Effe, you can connect your front-end to: 555 | 556 | - WebSockets for real-time communication 557 | - REST APIs for traditional request/response 558 | - AWS EventBridge for serverless event routing 559 | - Kafka topics for high-throughput messaging 560 | - Momento Topics for serverless pub/sub 561 | - Custom protocols via custom transports 562 | - Other EventHubs for federated architectures 563 | 564 | The possibilities are endless. Mix, match, and compose to build the exact event architecture your application needs. 565 | -------------------------------------------------------------------------------- /assets/eventhub-overview.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /assets/eventhub-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EffeDev/event-hub/3c486e002f46b15bde74773d1a48ea6b171a1440/assets/eventhub-overview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effedev/effe", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "Events For Front End (Effe). Framework agnostic EventHub extending your backend event bus to your frontend.", 6 | "main": "dist/index.cjs.js", 7 | "module": "dist/index.esm.js", 8 | "browser": "dist/index.umd.min.js", 9 | "types": "dist/index.d.ts", 10 | "type": "module", 11 | "scripts": {}, 12 | "engines": { 13 | "node": ">=22.0.0" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/serverless-dna/effe.git" 18 | }, 19 | "keywords": [ 20 | "EDA", 21 | "event", 22 | "bus" 23 | ], 24 | "author": "Michael Walmsley", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/serverless-dna/effe/issues" 28 | }, 29 | "homepage": "https://github.com/serverless-dna/effe#readme", 30 | "devDependencies": { 31 | "@eslint/js": "^9.24.0", 32 | "@rollup/plugin-commonjs": "^28.0.1", 33 | "@rollup/plugin-node-resolve": "^15.3.0", 34 | "@rollup/plugin-terser": "^0.4.4", 35 | "@rollup/plugin-typescript": "^12.1.1", 36 | "@semantic-release/changelog": "^6.0.3", 37 | "@semantic-release/github": "^11.0.1", 38 | "@types/ws": "^8.5.13", 39 | "@typescript-eslint/eslint-plugin": "^8.13.0", 40 | "@typescript-eslint/parser": "^8.13.0", 41 | "eslint": "^9.24.0", 42 | "eslint-config-prettier": "^9.1.0", 43 | "eslint-plugin-import": "^2.31.0", 44 | "eslint-plugin-prettier": "^5.2.1", 45 | "eslint-plugin-simple-import-sort": "^12.1.1", 46 | "eslint-plugin-tsdoc": "^0.3.0", 47 | "eslint-plugin-unused-imports": "^4.1.4", 48 | "globals": "^16.0.0", 49 | "husky": "^9.1.6", 50 | "jest": "^29.7.0", 51 | "prettier": "^3.3.3", 52 | "rimraf": "^6.0.1", 53 | "rollup": "^4.24.4", 54 | "rollup-plugin-node-polyfills": "^0.2.1", 55 | "semantic-release": "^24.2.0", 56 | "ts-jest": "^29.2.5", 57 | "typedoc": "^0.26.11", 58 | "typescript": "^5.6.3" 59 | }, 60 | "optionalDependencies": { 61 | "@types/jest": "^29.5.14" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/event-hub/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { builtinModules } from 'module'; 2 | 3 | import jsPlugin from '@eslint/js'; 4 | // eslint-disable-next-line import/no-unresolved 5 | import tsPlugin from '@typescript-eslint/eslint-plugin'; 6 | // eslint-disable-next-line import/no-unresolved 7 | import tsParser from '@typescript-eslint/parser'; 8 | import prettierConfig from 'eslint-config-prettier'; 9 | import importsPlugin from 'eslint-plugin-import'; 10 | import sortImportsPlugin from 'eslint-plugin-simple-import-sort'; 11 | import tsdocPlugin from 'eslint-plugin-tsdoc'; 12 | import unusedImportsPlugin from 'eslint-plugin-unused-imports'; 13 | import globals from 'globals'; 14 | 15 | export default [ 16 | { 17 | // Blacklisted Folders, including **/node_modules/ and .git/ 18 | ignores: ['dist/'], 19 | }, 20 | { 21 | // All files 22 | files: ['**/*.js', '**/*.cjs', '**/*.mjs', '**/*.jsx', '**/*.ts', '**/*.tsx'], 23 | plugins: { 24 | import: importsPlugin, 25 | 'unused-imports': unusedImportsPlugin, 26 | 'simple-import-sort': sortImportsPlugin, 27 | 'tsdoc-import': tsdocPlugin, 28 | }, 29 | languageOptions: { 30 | globals: { 31 | ...globals.node, 32 | ...globals.browser, 33 | }, 34 | parserOptions: { 35 | // Eslint doesn't supply ecmaVersion in `parser.js` `context.parserOptions` 36 | // This is required to avoid ecmaVersion < 2015 error or 'import' / 'export' error 37 | ecmaVersion: 'latest', 38 | sourceType: 'module', 39 | }, 40 | }, 41 | settings: { 42 | 'import/parsers': { 43 | // Workaround until import supports flat config 44 | // https://github.com/import-js/eslint-plugin-import/issues/2556 45 | espree: ['.js', '.cjs', '.mjs', '.jsx'], 46 | }, 47 | }, 48 | rules: { 49 | ...jsPlugin.configs.recommended.rules, 50 | ...importsPlugin.configs.recommended.rules, 51 | 52 | // Imports 53 | 'unused-imports/no-unused-vars': [ 54 | 'warn', 55 | { 56 | vars: 'all', 57 | varsIgnorePattern: '^_', 58 | args: 'after-used', 59 | argsIgnorePattern: '^_', 60 | }, 61 | ], 62 | 'unused-imports/no-unused-imports': ['warn'], 63 | 'import/first': ['warn'], 64 | 'import/newline-after-import': ['warn'], 65 | 'import/no-named-as-default': ['off'], 66 | 'simple-import-sort/exports': ['warn'], 67 | 'lines-between-class-members': ['warn', 'always', { exceptAfterSingleLine: true }], 68 | 'simple-import-sort/imports': [ 69 | 'warn', 70 | { 71 | groups: [ 72 | // Side effect imports. 73 | ['^\\u0000'], 74 | // Node.js builtins, react, and third-party packages. 75 | [`^(${builtinModules.join('|')})(/|$)`], 76 | // Path aliased root, parent imports, and just `..`. 77 | ['^@/', '^\\.\\.(?!/?$)', '^\\.\\./?$'], 78 | // Relative imports, same-folder imports, and just `.`. 79 | ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], 80 | // Style imports. 81 | ['^.+\\.s?css$'], 82 | ], 83 | }, 84 | ], 85 | }, 86 | }, 87 | { 88 | // TypeScript files 89 | files: ['**/*.ts', '**/*.tsx'], 90 | plugins: { 91 | '@typescript-eslint': tsPlugin, 92 | }, 93 | languageOptions: { 94 | parser: tsParser, 95 | parserOptions: { 96 | project: './tsconfig.lint.json', 97 | }, 98 | }, 99 | settings: { 100 | ...importsPlugin.configs.typescript.settings, 101 | 'import/resolver': { 102 | ...importsPlugin.configs.typescript.settings['import/resolver'], 103 | typescript: { 104 | alwaysTryTypes: true, 105 | project: './tsconfig.json', 106 | }, 107 | }, 108 | }, 109 | rules: { 110 | ...importsPlugin.configs.typescript.rules, 111 | ...tsPlugin.configs['eslint-recommended'].overrides[0].rules, 112 | ...tsPlugin.configs.recommended.rules, 113 | 114 | // Typescript Specific 115 | '@typescript-eslint/no-unused-vars': 'off', // handled by unused-imports 116 | '@typescript-eslint/explicit-function-return-type': 'off', 117 | '@typescript-eslint/no-explicit-any': 'off', 118 | '@typescript-eslint/no-empty-interface': 'off', 119 | }, 120 | }, 121 | { 122 | // Prettier Overrides 123 | files: ['**/*.js', '**/*.cjs', '**/*.mjs', '**/*.jsx', '**/*.ts', '**/*.tsx'], 124 | rules: { 125 | ...prettierConfig.rules, 126 | }, 127 | }, 128 | ]; 129 | -------------------------------------------------------------------------------- /packages/event-hub/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | transform: { 4 | '^.+\\.tsx?$': ['ts-jest', { tsconfig: { target: 'es6' } }], 5 | }, 6 | collectCoverage: true, 7 | collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/index.ts'], 8 | coverageReporters: ['lcovonly', 'text', 'text-summary'], 9 | coverageThreshold: { 10 | global: { 11 | branches: 80, // Restored to original value 12 | functions: 80, // Restored to original value 13 | lines: 80, // Restored to original value 14 | statements: -10, // Original value 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/event-hub/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "event-hub", 3 | "version": "0.1.0", 4 | "description": "Events For Front End (Effe). Framework agnostic EventHub extending your backend event bus to your frontend.", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "browser": "dist/index.umd.min.js", 8 | "types": "dist/index.d.ts", 9 | "type": "module", 10 | "scripts": { 11 | "prebuild": "rimraf dist/* && npm run lint", 12 | "build": "rollup -c", 13 | "test": "jest", 14 | "lint": "eslint", 15 | "test:ci": "jest --ci", 16 | "lint:fix": "eslint --fix", 17 | "postversion": "npm run build", 18 | "release": "semantic-release" 19 | }, 20 | "engines": { 21 | "node": ">=22.0.0" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/serverless-dna/effe.git" 26 | }, 27 | "keywords": [ 28 | "EDA", 29 | "event", 30 | "bus" 31 | ], 32 | "author": "Michael Walmsley", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/serverless-dna/effe/issues" 36 | }, 37 | "homepage": "https://github.com/serverless-dna/effe#readme", 38 | "packageManager": "pnpm@10.8.1", 39 | "dependencies": { 40 | "@gomomento/sdk-web": "^1.101.2", 41 | "isomorphic-ws": "^5.0.0", 42 | "ws": "^8.18.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/event-hub/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 5 | import terser from '@rollup/plugin-terser'; 6 | import typescript from '@rollup/plugin-typescript'; 7 | import nodePolyfills from 'rollup-plugin-node-polyfills'; 8 | 9 | const pkg = JSON.parse(readFileSync('./package.json')); 10 | 11 | const startsWithRegExp = (str) => RegExp(`^${str}`); 12 | 13 | export default [ 14 | { 15 | input: 'src/index.ts', 16 | output: [ 17 | { 18 | name: 'EventHub', 19 | file: pkg.browser, 20 | format: 'umd', 21 | plugins: [terser()], 22 | sourcemap: true, 23 | }, 24 | { 25 | name: 'EventHub', 26 | file: `${pkg.browser.replace(/\.min\.js$/, '.js')}`, 27 | format: 'umd', 28 | }, 29 | ], 30 | plugins: [commonjs(), nodePolyfills(), nodeResolve({ browser: true, preferBuiltins: false }), typescript()], 31 | }, 32 | { 33 | input: 'src/index.ts', 34 | external: [ 35 | ...Object.keys(pkg.dependencies || {}).map(startsWithRegExp), 36 | // ...Object.keys(pkg.peerDependencies || {}).map(startsWithRegExp), 37 | ], 38 | plugins: [commonjs(), typescript()], 39 | output: [ 40 | { file: pkg.main, format: 'cjs' }, 41 | { file: pkg.module, format: 'es' }, 42 | ], 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /packages/event-hub/src/channel.spec.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from "./channel"; 2 | 3 | describe('[Channel] Basic Operations', () => { 4 | let channel: Channel; 5 | 6 | beforeEach(() => { 7 | channel = new Channel('test'); 8 | }); 9 | 10 | it('lastEvent should be undefined when no events published', ()=> { 11 | expect(channel.lastEvent).toBeUndefined(); 12 | }); 13 | 14 | it('callbacks should be empty with no subscribers', () => { 15 | expect(Object.keys(channel.callbacks).length).toBe(0); 16 | }); 17 | 18 | it('Should have a name property [test]', () => { 19 | expect(channel.name).toBe('test'); 20 | }); 21 | }); 22 | 23 | describe('[Channel] Subscription Management', () => { 24 | let channel: Channel; 25 | let eventData: boolean; 26 | let subscription: any; 27 | 28 | beforeEach(() => { 29 | channel = new Channel('test'); 30 | eventData = false; 31 | subscription = channel.subscribe((data: boolean) => { 32 | eventData = data; 33 | }); 34 | }); 35 | 36 | it('Should return 1 for the first subscriber', () => { 37 | expect(subscription.id).toBe(1); 38 | }); 39 | 40 | it('Should call each callback when an event is published', async () => { 41 | await channel.publish(true); 42 | expect(eventData).toBeTruthy(); 43 | }); 44 | 45 | it('Should return the last event when called', async () => { 46 | await channel.publish(true); 47 | expect(channel.lastEvent).toBeTruthy(); 48 | }); 49 | 50 | it('Should remove the subscriber when I call unsubscribe', async () => { 51 | subscription.unsubscribe(); 52 | expect(Object.keys(channel.callbacks).length).toBe(0); 53 | }); 54 | 55 | it('Should replay the last event when I subscribe with replay active', async () => { 56 | await channel.publish(true); 57 | eventData = false; 58 | channel.subscribe((data:boolean) => { 59 | eventData = data; 60 | }, { replay: true }); 61 | expect(eventData).toBeTruthy(); 62 | }); 63 | }); 64 | 65 | describe('[Channel] Group Subscription Management', () => { 66 | let channel: Channel; 67 | let receivedMessages: string[]; 68 | 69 | beforeEach(() => { 70 | channel = new Channel('test'); 71 | receivedMessages = []; 72 | }); 73 | 74 | it('Should add subscribers to the same group', async () => { 75 | const callback1 = (msg: string) => { receivedMessages.push(`cb1: ${msg}`) }; 76 | const callback2 = (msg: string) => { receivedMessages.push(`cb2: ${msg}`) }; 77 | 78 | channel.subscribe(callback1, { group: 'testGroup' }); 79 | channel.subscribe(callback2, { group: 'testGroup' }); 80 | 81 | await channel.publish('hello'); 82 | expect(receivedMessages).toEqual(['cb1: hello', 'cb2: hello']); 83 | }); 84 | 85 | it('Should unsubscribe all callbacks in a group', async () => { 86 | const callback1 = jest.fn(); 87 | const callback2 = jest.fn(); 88 | const callback3 = jest.fn(); 89 | 90 | channel.subscribe(callback1, { group: 'group1' }); 91 | channel.subscribe(callback2, { group: 'group1' }); 92 | channel.subscribe(callback3, { group: 'group2' }); 93 | 94 | channel.unsubscribeGroup('group1'); 95 | 96 | await channel.publish('test'); 97 | expect(callback1).not.toHaveBeenCalled(); 98 | expect(callback2).not.toHaveBeenCalled(); 99 | expect(callback3).toHaveBeenCalledWith('test'); 100 | }); 101 | 102 | it('Should handle unsubscribe for non-existent group', () => { 103 | channel.unsubscribeGroup('nonexistent'); 104 | // Should not throw any errors 105 | expect(channel.callbacks.size).toBe(0); 106 | }); 107 | 108 | it('Should remove callback from group when unsubscribed individually', async () => { 109 | const callback = jest.fn(); 110 | const subscription = channel.subscribe(callback, { group: 'testGroup' }); 111 | 112 | subscription.unsubscribe(); 113 | await channel.publish('test'); 114 | 115 | expect(callback).not.toHaveBeenCalled(); 116 | }); 117 | 118 | it('Should handle multiple groups for the same channel', async () => { 119 | const callback1 = jest.fn(); 120 | const callback2 = jest.fn(); 121 | const callback3 = jest.fn(); 122 | 123 | channel.subscribe(callback1, { group: 'group1' }); 124 | channel.subscribe(callback2, { group: 'group2' }); 125 | channel.subscribe(callback3, { group: 'group1' }); 126 | 127 | channel.unsubscribeGroup('group1'); 128 | await channel.publish('test'); 129 | 130 | expect(callback1).not.toHaveBeenCalled(); 131 | expect(callback2).toHaveBeenCalledWith('test'); 132 | expect(callback3).not.toHaveBeenCalled(); 133 | }); 134 | 135 | it('Should handle replay option with group subscriptions', async () => { 136 | const callback1 = jest.fn(); 137 | const callback2 = jest.fn(); 138 | 139 | await channel.publish('initial'); 140 | 141 | channel.subscribe(callback1, { group: 'group1', replay: true }); 142 | channel.subscribe(callback2, { group: 'group1', replay: true }); 143 | 144 | expect(callback1).toHaveBeenCalledWith('initial'); 145 | expect(callback2).toHaveBeenCalledWith('initial'); 146 | }); 147 | }); 148 | 149 | describe('[Channel] Error Handling', () => { 150 | let channel: Channel; 151 | let originalConsoleError: typeof console.error; 152 | let mockConsoleError: jest.SpyInstance; 153 | 154 | beforeEach(() => { 155 | channel = new Channel('test'); 156 | // Store the original console.error 157 | originalConsoleError = console.error; 158 | // Create a mock for console.error 159 | mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); 160 | }); 161 | 162 | afterEach(() => { 163 | // Restore the original console.error 164 | mockConsoleError.mockRestore(); 165 | console.error = originalConsoleError; 166 | }); 167 | 168 | it('Should catch and log error when callback throws', async () => { 169 | const errorMessage = 'Test error in callback'; 170 | const failingCallback = () => { 171 | throw new Error(errorMessage); 172 | }; 173 | 174 | // Subscribe with the failing callback 175 | channel.subscribe(failingCallback); 176 | 177 | // Publish an event to trigger the callback 178 | await channel.publish('test message'); 179 | 180 | // Verify console.error was called with the expected error message 181 | expect(mockConsoleError).toHaveBeenCalled(); 182 | expect(mockConsoleError.mock.calls[0][0]).toBe(`Error in channel test callback:`); 183 | expect(mockConsoleError.mock.calls[0][1]).toBeInstanceOf(Error); 184 | }); 185 | 186 | it('Should continue executing other callbacks when one fails', async () => { 187 | const successCallback = jest.fn(); 188 | const failingCallback = () => { 189 | throw new Error('Test error'); 190 | }; 191 | 192 | // Subscribe both callbacks 193 | channel.subscribe(failingCallback); 194 | channel.subscribe(successCallback); 195 | 196 | // Publish an event 197 | await channel.publish('test message'); 198 | 199 | // Verify the success callback was still called 200 | expect(successCallback).toHaveBeenCalledWith('test message'); 201 | // Verify the error was logged 202 | expect(mockConsoleError).toHaveBeenCalled(); 203 | }); 204 | 205 | it('Should throw a TypeError when an invalid callback is provided', () => { 206 | expect(() => { 207 | channel.subscribe('not a function' as any); 208 | }).toThrow(TypeError); 209 | expect(() => { 210 | channel.subscribe('not a function' as any); 211 | }).toThrow('Callback must be a function'); 212 | }); 213 | 214 | it('Should handle async callback errors in debug mode', async () => { 215 | const debugChannel = new Channel('test'); 216 | const successCallback = jest.fn(); 217 | const failingCallback = async () => { 218 | throw new Error('Async error'); 219 | }; 220 | 221 | debugChannel.subscribe(failingCallback); 222 | debugChannel.subscribe(successCallback); 223 | 224 | // This should no longer throw 225 | await debugChannel.publish('test message'); 226 | 227 | // Both callbacks should have been attempted 228 | expect(successCallback).toHaveBeenCalledWith('test message'); 229 | // Error should have been logged 230 | expect(mockConsoleError).toHaveBeenCalled(); 231 | // Error message should match 232 | expect(mockConsoleError.mock.calls[0][0]).toBe('Error in channel test callback:'); 233 | }); 234 | 235 | it('Should directly test debug mode error handling', async () => { 236 | // Create a channel in debug mode 237 | const debugChannel = new Channel('test-debug'); 238 | 239 | // Create a spy on the console.error method 240 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 241 | 242 | // Create a callback that will throw an error 243 | const failingCallback = async () => { 244 | throw new Error('Debug mode error'); 245 | }; 246 | 247 | // Subscribe the failing callback 248 | debugChannel.subscribe(failingCallback); 249 | 250 | // Publish to trigger the error in debug mode 251 | await debugChannel.publish('test message'); 252 | 253 | // Verify the error was logged 254 | expect(errorSpy).toHaveBeenCalled(); 255 | expect(errorSpy).toHaveBeenCalledWith( 256 | 'Error in channel test-debug callback:', 257 | expect.any(Error) 258 | ); 259 | 260 | // Verify metrics were updated 261 | expect(debugChannel.metrics.errorCount).toBe(1); 262 | 263 | // Clean up 264 | errorSpy.mockRestore(); 265 | }); 266 | 267 | it('Should directly test production mode error handling', async () => { 268 | // Create a channel in production mode 269 | const prodChannel = new Channel('test-prod'); 270 | 271 | // Create a spy on the console.error method 272 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 273 | 274 | // Create a callback that returns a rejected promise 275 | const failingCallback = () => Promise.reject(new Error('Production mode error')); 276 | 277 | // Subscribe the failing callback 278 | prodChannel.subscribe(failingCallback); 279 | 280 | // Publish to trigger the error in production mode 281 | await prodChannel.publish('test message'); 282 | 283 | // Verify the error was logged 284 | expect(errorSpy).toHaveBeenCalled(); 285 | expect(errorSpy).toHaveBeenCalledWith( 286 | 'Error in channel test-prod callback:', 287 | expect.any(Error) 288 | ); 289 | 290 | // Verify metrics were updated 291 | expect(prodChannel.metrics.errorCount).toBe(1); 292 | 293 | // Clean up 294 | errorSpy.mockRestore(); 295 | }); 296 | }); 297 | 298 | describe('[Channel] Metrics', () => { 299 | let channel: Channel; 300 | let mockDate: number; 301 | 302 | beforeEach(() => { 303 | channel = new Channel('test'); 304 | mockDate = Date.now(); 305 | jest.spyOn(Date, 'now').mockImplementation(() => mockDate); 306 | }); 307 | 308 | afterEach(() => { 309 | jest.restoreAllMocks(); 310 | }); 311 | 312 | it('Should have initial metrics values of zero', () => { 313 | expect(channel.metrics.publishCount).toBe(0); 314 | expect(channel.metrics.errorCount).toBe(0); 315 | expect(channel.metrics.lastPublishTime).toBe(0); 316 | }); 317 | 318 | it('Should increment publishCount when publishing events', async () => { 319 | await channel.publish('test1'); 320 | expect(channel.metrics.publishCount).toBe(1); 321 | 322 | await channel.publish('test2'); 323 | expect(channel.metrics.publishCount).toBe(2); 324 | }); 325 | 326 | it('Should update lastPublishTime when publishing events', async () => { 327 | await channel.publish('test'); 328 | expect(channel.metrics.lastPublishTime).toBe(mockDate); 329 | }); 330 | 331 | it('Should increment errorCount when callbacks throw errors', async () => { 332 | const errorCallback = () => { 333 | throw new Error('Test error'); 334 | }; 335 | 336 | // Suppress console.error for this test 337 | jest.spyOn(console, 'error').mockImplementation(() => {}); 338 | 339 | channel.subscribe(errorCallback); 340 | await channel.publish('test'); 341 | 342 | expect(channel.metrics.errorCount).toBe(1); 343 | 344 | // Multiple errors should increment counter multiple times 345 | await channel.publish('test again'); 346 | expect(channel.metrics.errorCount).toBe(2); 347 | }); 348 | 349 | it('Should increment errorCount for async errors in debug mode', async () => { 350 | const debugChannel = new Channel('test'); 351 | const errorCallback = async () => { 352 | throw new Error('Async error'); 353 | }; 354 | 355 | // Suppress console.error for this test 356 | jest.spyOn(console, 'error').mockImplementation(() => {}); 357 | 358 | debugChannel.subscribe(errorCallback); 359 | await debugChannel.publish('test'); 360 | 361 | expect(debugChannel.metrics.errorCount).toBe(1); 362 | }); 363 | }); 364 | 365 | 366 | 367 | 368 | -------------------------------------------------------------------------------- /packages/event-hub/src/channel.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | CallbackList, 4 | ChannelMetrics, 5 | EventCallback, 6 | IChannel, 7 | SubscribeOptions, 8 | Subscription 9 | } from "./types"; 10 | 11 | /** 12 | * Manages callback subscribers for a channel. 13 | * 14 | * @class Channel 15 | * @implements {IChannel} 16 | * @template TData The type of event data that this channel handles 17 | * @property {string} _name - The name of the channel 18 | * @property {TData | undefined} _lastEvent - The last event that was published on the channel 19 | * @property {number} _lastId - The last assigned callback ID 20 | * @property {CallbackList} _callbacks - The callbacks that are subscribed to the channel 21 | * 22 | * @description 23 | * The Channel class is responsible for managing subscriptions and publications for a specific event type. 24 | * It maintains a list of callback functions and allows publishing events to all subscribers. 25 | * It also keeps track of the last event published on the channel for potential replay functionality. 26 | * 27 | * @example 28 | * const channel = new Channel('myChannel'); 29 | * const subscription = channel.subscribe((message) => console.log(message)); 30 | * channel.publish('Hello, World!'); 31 | * // Output: Hello, World! 32 | */ 33 | export class Channel implements IChannel { 34 | private readonly _name: string; 35 | private _lastEvent: TData|undefined; 36 | private _lastId = 0; 37 | private _callbacks: CallbackList = new Map(); 38 | private _groups: Map> = new Map(); 39 | private _metrics: ChannelMetrics = { 40 | publishCount: 0, 41 | errorCount: 0, 42 | lastPublishTime: 0, 43 | }; 44 | 45 | /** 46 | * Creates a new Channel instance. 47 | * 48 | * @param {string} name - The name of the channel. This is used to identify the channel within the EventHub. 49 | */ 50 | constructor(name: string) { 51 | this._name = name; 52 | } 53 | 54 | /** 55 | * Generates the next unique ID for registered callback functions. 56 | * 57 | * @private 58 | * @returns {number} The next available callback ID. 59 | */ 60 | private getNextId(): number { 61 | return ++this._lastId; 62 | } 63 | 64 | 65 | 66 | private addToGroup(group: string, subscription: Subscription): void { 67 | if (!this._groups.has(group)) { 68 | this._groups.set(group, new Set()); 69 | } 70 | this._groups.get(group)!.add(subscription); 71 | } 72 | 73 | /** 74 | * Unsubscribes all callbacks in a specific group 75 | * 76 | * @param group The name of the group to unsubscribe 77 | */ 78 | unsubscribeGroup(group: string): void { 79 | const subscriptions = this._groups.get(group); 80 | if (subscriptions) { 81 | subscriptions.forEach(sub => sub.unsubscribe()); 82 | this._groups.delete(group); 83 | } 84 | } 85 | 86 | /** 87 | * Subscribes to events on the channel. Each event received will be passed to the callback function. 88 | * 89 | * @param {EventCallback} callback - The function to be called when an event is published on this channel. 90 | * @param {SubscribeOptions} [options] - Optional settings for the subscription including replay and group. 91 | * @returns {Subscription} An object containing the unsubscribe method and the subscription ID. 92 | * @throws {TypeError} If the callback is not a function 93 | */ 94 | subscribe(callback: EventCallback, options?: SubscribeOptions): Subscription { 95 | if (!callback || typeof callback !== 'function') { 96 | throw new TypeError('Callback must be a function'); 97 | } 98 | const id = this.getNextId(); 99 | 100 | // Simplify the wrapped callback - don't reject promises 101 | const wrappedCallback: EventCallback = async (message) => { 102 | try { 103 | return await callback(message); 104 | } catch (error) { 105 | console.error(`Error in channel ${this.name} callback:`, error); 106 | this._metrics.errorCount++; 107 | // Return undefined instead of rejecting 108 | return undefined; 109 | } 110 | }; 111 | 112 | const lastEvent = this._lastEvent; 113 | this._callbacks.set(id, wrappedCallback); 114 | 115 | const subscription = { 116 | unsubscribe: () => { 117 | this._callbacks.delete(id); 118 | // Remove from group if part of one 119 | if (options?.group) { 120 | const groupSubs = this._groups.get(options.group); 121 | if (groupSubs) { 122 | groupSubs.delete(subscription); 123 | if (groupSubs.size === 0) { 124 | this._groups.delete(options.group); 125 | } 126 | } 127 | } 128 | }, 129 | id, 130 | }; 131 | 132 | // Add to group if specified 133 | if (options?.group) { 134 | this.addToGroup(options.group, subscription); 135 | } 136 | 137 | // replay the last event 138 | if(options?.replay && lastEvent !== undefined) { 139 | wrappedCallback(lastEvent); 140 | } 141 | 142 | return subscription; 143 | } 144 | 145 | 146 | 147 | /** 148 | * Publishes an event to the channel, notifying all subscribers. 149 | * 150 | * @param {TData} data - The event data to be published to all subscribers. 151 | */ 152 | async publish(data: TData): Promise { 153 | this._metrics.publishCount++; 154 | this._metrics.lastPublishTime = Date.now(); 155 | this._lastEvent = data; 156 | 157 | const callbacks = Array.from(this._callbacks.values()); 158 | 159 | // Execute all callbacks in parallel and don't worry about errors 160 | // since they're already handled in the wrapped callbacks 161 | await Promise.allSettled(callbacks.map(callback => callback(data))); 162 | } 163 | 164 | /** 165 | * Retrieves the last event that was published on the channel. 166 | * 167 | * @returns {TData | undefined} The last event that was published on the channel, or undefined if no event has been published. 168 | */ 169 | get lastEvent(): TData | undefined { 170 | return this._lastEvent; 171 | } 172 | 173 | /** 174 | * Retrieves the name of the channel. 175 | * 176 | * @returns {string} The name of the channel. 177 | */ 178 | get name(): string { 179 | return this._name; 180 | } 181 | 182 | 183 | /** 184 | * Retrieves the list of callbacks that are subscribed to the channel. 185 | * 186 | * @returns {CallbackList} A Map containing all the callbacks subscribed to the channel, keyed by their subscription IDs. 187 | */ 188 | get callbacks(): CallbackList { 189 | return this._callbacks; 190 | } 191 | 192 | /** 193 | * Retrieves the metrics for the channel. 194 | * 195 | * @returns {ChannelMetrics} The metrics for the channel. 196 | */ 197 | get metrics(): ChannelMetrics { 198 | return this._metrics; 199 | } 200 | } 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /packages/event-hub/src/connector.spec.ts: -------------------------------------------------------------------------------- 1 | import { SinkConnector,SourceConnector } from './connector'; 2 | import { EventHub } from './event-hub'; 3 | import { SinkTransport,SourceTransport } from './transport'; 4 | import { Subscription } from './types'; 5 | 6 | // Mock implementations for testing 7 | class MockSourceTransport extends SourceTransport { 8 | public disconnectCalled = false; 9 | 10 | async connect(): Promise { 11 | this._connected = true; 12 | } 13 | 14 | async disconnect(): Promise { 15 | this._connected = false; 16 | this.disconnectCalled = true; 17 | } 18 | 19 | // Method to simulate receiving data from external source 20 | async simulateReceiveData(data: object): Promise { 21 | if (this._onDataHandler) { 22 | await this._onDataHandler(data); 23 | } 24 | } 25 | } 26 | 27 | class MockSinkTransport extends SinkTransport { 28 | public sentMessages: any[] = []; 29 | public disconnectCalled = false; 30 | 31 | async connect(): Promise { 32 | this._connected = true; 33 | } 34 | 35 | async disconnect(): Promise { 36 | this._connected = false; 37 | this.disconnectCalled = true; 38 | } 39 | 40 | protected async sendMessage(data: string): Promise { 41 | this.sentMessages.push(data); 42 | } 43 | } 44 | 45 | // Concrete implementations for testing 46 | class TestSourceConnector extends SourceConnector { 47 | constructor(eventHub: EventHub, transport: MockSourceTransport, channel: string) { 48 | super(eventHub, transport, channel); 49 | } 50 | 51 | // Expose protected properties for testing 52 | getChannel(): string { 53 | return this.channel; 54 | } 55 | } 56 | 57 | class TestSinkConnector extends SinkConnector { 58 | constructor(eventHub: EventHub, transport: MockSinkTransport, channel: string) { 59 | super(eventHub, transport, channel); 60 | } 61 | 62 | // Expose protected properties for testing 63 | getSubscription(): Subscription | undefined { 64 | return this.subscription; 65 | } 66 | } 67 | 68 | describe('SourceConnector', () => { 69 | let eventHub: EventHub; 70 | let transport: MockSourceTransport; 71 | let connector: TestSourceConnector; 72 | const channelName = 'test-channel'; 73 | 74 | beforeEach(() => { 75 | eventHub = new EventHub(); 76 | transport = new MockSourceTransport('test-source-transport'); 77 | connector = new TestSourceConnector(eventHub, transport, channelName); 78 | }); 79 | 80 | it('should initialize with correct properties', () => { 81 | expect(connector.eventHub).toBe(eventHub); 82 | expect(connector.transport).toBe(transport); 83 | expect(connector.getChannel()).toBe(channelName); 84 | }); 85 | 86 | it('should connect to transport and register data handler', async () => { 87 | await connector.connect(); 88 | expect(transport.isConnected()).toBe(true); 89 | }); 90 | 91 | it('should publish received data to EventHub', async () => { 92 | const publishSpy = jest.spyOn(eventHub, 'publish'); 93 | const testData = { message: 'test data' }; 94 | 95 | await connector.connect(); 96 | await transport.simulateReceiveData(testData); 97 | 98 | expect(publishSpy).toHaveBeenCalledWith(channelName, testData); 99 | }); 100 | 101 | it('should disconnect from transport', async () => { 102 | await connector.connect(); 103 | await connector.disconnect(); 104 | expect(transport.disconnectCalled).toBe(true); 105 | }); 106 | }); 107 | 108 | describe('SinkConnector', () => { 109 | let eventHub: EventHub; 110 | let transport: MockSinkTransport; 111 | let connector: TestSinkConnector; 112 | const channelName = 'test-channel'; 113 | 114 | beforeEach(() => { 115 | eventHub = new EventHub(); 116 | transport = new MockSinkTransport('test-sink-transport'); 117 | connector = new TestSinkConnector(eventHub, transport, channelName); 118 | }); 119 | 120 | it('should initialize with correct properties', () => { 121 | expect(connector.eventHub).toBe(eventHub); 122 | expect(connector.transport).toBe(transport); 123 | expect(connector.channel).toBe(channelName); 124 | }); 125 | 126 | it('should connect to transport and subscribe to EventHub channel', async () => { 127 | await connector.connect(); 128 | expect(transport.isConnected()).toBe(true); 129 | expect(connector.getSubscription()).toBeDefined(); 130 | }); 131 | 132 | it('should send data to transport when event is published', async () => { 133 | const testData = { message: 'test data' }; 134 | 135 | await connector.connect(); 136 | await eventHub.publish(channelName, testData); 137 | 138 | // Since the transport is mocked, we need to check if the send method was called 139 | expect(transport.sentMessages.length).toBeGreaterThan(0); 140 | }); 141 | 142 | it('should unsubscribe from EventHub and disconnect transport', async () => { 143 | await connector.connect(); 144 | const subscription = connector.getSubscription(); 145 | const unsubscribeSpy = jest.spyOn(subscription!, 'unsubscribe'); 146 | 147 | await connector.disconnect(); 148 | 149 | expect(unsubscribeSpy).toHaveBeenCalled(); 150 | expect(transport.disconnectCalled).toBe(true); 151 | }); 152 | 153 | it('should handle disconnect when not subscribed', async () => { 154 | // Don't connect first 155 | await connector.disconnect(); 156 | expect(transport.disconnectCalled).toBe(true); 157 | }); 158 | }); 159 | 160 | describe('Integration between Connectors', () => { 161 | let eventHub: EventHub; 162 | let sourceTransport: MockSourceTransport; 163 | let sinkTransport: MockSinkTransport; 164 | let sourceConnector: TestSourceConnector; 165 | let sinkConnector: TestSinkConnector; 166 | const channelName = 'test-channel'; 167 | 168 | beforeEach(() => { 169 | eventHub = new EventHub(); 170 | sourceTransport = new MockSourceTransport('test-source-transport'); 171 | sinkTransport = new MockSinkTransport('test-sink-transport'); 172 | sourceConnector = new TestSourceConnector(eventHub, sourceTransport, channelName); 173 | sinkConnector = new TestSinkConnector(eventHub, sinkTransport, channelName); 174 | }); 175 | 176 | it('should form a complete data flow from source to sink', async () => { 177 | const testData = { message: 'test data' }; 178 | 179 | // Connect both connectors 180 | await sourceConnector.connect(); 181 | await sinkConnector.connect(); 182 | 183 | // Simulate data coming from external source 184 | await sourceTransport.simulateReceiveData(testData); 185 | 186 | // Verify data was sent to the sink transport 187 | expect(sinkTransport.sentMessages.length).toBeGreaterThan(0); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /packages/event-hub/src/connector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Core Connector Classes 3 | * 4 | * @description 5 | * Connectors are one-way data flow components that connect to a transport 6 | * and publish/subscribe to the EventHub. They provide the bridge between 7 | * external data sources/sinks and the internal event system. 8 | */ 9 | import { EventHub } from './event-hub'; 10 | import { SinkTransport,SourceTransport } from './transport'; 11 | import { Subscription } from './types'; 12 | 13 | /** 14 | * Implements an inbound data flow from an external transport to the EventHub 15 | * 16 | * @template TInput The type of raw data received from the transport 17 | * @template TOutput The type of processed data published to the EventHub 18 | * 19 | * @description 20 | * SourceConnector establishes a one-way data flow from an external source into the EventHub. 21 | * It connects to a source transport that receives data from an external system and publishes 22 | * that data to a specified channel in the EventHub. 23 | * 24 | * @example 25 | * class WebSocketSourceConnector extends SourceConnector { 26 | * constructor(eventHub: EventHub) { 27 | * super( 28 | * eventHub, 29 | * new WebSocketTransport("wss://api.example.com"), 30 | * "websocket-events" 31 | * ); 32 | * } 33 | * } 34 | * 35 | * const connector = new WebSocketSourceConnector(eventHub); 36 | * await connector.connect(); 37 | * // Now websocket messages will be published to "websocket-events" channel 38 | */ 39 | export abstract class SourceConnector { 40 | /** The EventHub instance where events will be published */ 41 | readonly eventHub: EventHub; 42 | /** The transport that receives data from the external source */ 43 | readonly transport: SourceTransport; 44 | /** The channel where received events will be published */ 45 | protected channel: string; 46 | 47 | /** 48 | * Creates a new SourceConnector instance 49 | * 50 | * @param eventHub The EventHub instance to publish events to 51 | * @param transport The transport that will receive external data 52 | * @param channel The channel name where events will be published 53 | */ 54 | constructor( 55 | eventHub: EventHub, 56 | transport: SourceTransport, 57 | channel: string 58 | ) { 59 | this.eventHub = eventHub; 60 | this.transport = transport; 61 | this.channel = channel; 62 | } 63 | 64 | /** 65 | * Establishes the connection to the external source 66 | * 67 | * @description 68 | * This method performs two steps: 69 | * 1. Registers a handler with the transport to publish received data to the EventHub 70 | * 2. Connects the transport to start receiving data 71 | * 72 | * @throws {Error} If connection fails or EventHub is not available 73 | */ 74 | async connect(): Promise { 75 | // First register the EventHub Handler 76 | this.transport.onData(async (data: TOutput) => { 77 | await this.eventHub.publish(this.channel, data); 78 | }); 79 | 80 | await this.transport.connect(); 81 | }; 82 | 83 | /** 84 | * Terminates the connection to the external source 85 | * 86 | * @description 87 | * Disconnects the transport, which stops the flow of data from the external source. 88 | * Any queued or in-flight messages may be lost. 89 | * 90 | * @throws {Error} If disconnection fails 91 | */ 92 | async disconnect(): Promise { 93 | await this.transport.disconnect(); 94 | } 95 | } 96 | 97 | /** 98 | * Implements an outbound data flow from the EventHub to an external transport 99 | * 100 | * @template TInput The type of data received from the EventHub 101 | * @template TOutput The type of processed data sent to the transport 102 | * 103 | * @description 104 | * SinkConnector establishes a one-way data flow from the EventHub to an external system. 105 | * It subscribes to a specified channel in the EventHub and forwards all events to a 106 | * sink transport that sends the data to an external system. 107 | * 108 | * @example 109 | * class WebSocketSinkConnector extends SinkConnector { 110 | * constructor(eventHub: EventHub) { 111 | * super( 112 | * eventHub, 113 | * new WebSocketTransport("wss://api.example.com"), 114 | * "outbound-events" 115 | * ); 116 | * } 117 | * } 118 | * 119 | * const connector = new WebSocketSinkConnector(eventHub); 120 | * await connector.connect(); 121 | * // Now events published to "outbound-events" will be sent to the websocket 122 | */ 123 | export abstract class SinkConnector { 124 | /** The EventHub instance to subscribe to for events */ 125 | readonly eventHub: EventHub; 126 | /** The transport that sends data to the external system */ 127 | readonly transport: SinkTransport; 128 | /** The channel to subscribe to for events */ 129 | readonly channel: string; 130 | /** The subscription to the EventHub channel */ 131 | protected subscription?: Subscription; 132 | 133 | /** 134 | * Creates a new SinkConnector instance 135 | * 136 | * @param eventHub The EventHub instance to subscribe to 137 | * @param transport The transport that will send data externally 138 | * @param channel The channel name to subscribe to 139 | */ 140 | constructor( 141 | eventHub: EventHub, 142 | transport: SinkTransport, 143 | channel: string 144 | ) { 145 | this.eventHub = eventHub; 146 | this.transport = transport; 147 | this.channel = channel; 148 | } 149 | 150 | /** 151 | * Establishes the connection to the external system 152 | * 153 | * @description 154 | * This method performs two steps: 155 | * 1. Subscribes to the specified EventHub channel 156 | * 2. Connects the transport to enable sending data 157 | * 158 | * @throws {Error} If connection fails or EventHub is not available 159 | */ 160 | connect(): Promise { 161 | // Subscribe to the Channel to receive events 162 | this.subscription = this.eventHub.subscribe(this.channel, async (data: TInput) => { 163 | await this.transport.send(data); 164 | }); 165 | 166 | return this.transport.connect(); 167 | } 168 | 169 | /** 170 | * Terminates the connection to the external system 171 | * 172 | * @description 173 | * This method: 174 | * 1. Unsubscribes from the EventHub channel to stop receiving events 175 | * 2. Disconnects the transport 176 | * Any queued or in-flight messages may be lost. 177 | * 178 | * @throws {Error} If disconnection fails 179 | */ 180 | disconnect(): Promise { 181 | if (this.subscription) { 182 | this.subscription.unsubscribe(); 183 | } 184 | return this.transport.disconnect(); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /packages/event-hub/src/event-hub.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventHub } from './event-hub'; 2 | import {WildCardChannel } from './types'; 3 | 4 | describe('[EventHub]: subscribe', () => { 5 | let eventHub: EventHub; 6 | let eventData: boolean; 7 | let publishCount: number; 8 | 9 | beforeEach(() => { 10 | eventHub = new EventHub(); 11 | eventData = false; 12 | publishCount = 0; 13 | }); 14 | 15 | it('Should add a channel when I subscribe', () => { 16 | eventHub.subscribe('test', (data:boolean) => { 17 | eventData = data; 18 | publishCount++; 19 | }); 20 | // need to take into account the WildCardChannel 21 | expect(eventHub.channelCount).toBe(2); 22 | }); 23 | 24 | it('Should not add another channel when I subscribe to the same channel', 25 | () => { 26 | eventHub.subscribe('test', (data:boolean) => { 27 | eventData = data; 28 | }); 29 | eventHub.subscribe('test', (data:boolean) => { 30 | eventData = data; 31 | }); 32 | expect(eventHub.channelCount).toBe(2); 33 | }); 34 | 35 | it('Should add another channel when I subscribe to a unique name', () => { 36 | eventHub.subscribe('test', (data:boolean) => { 37 | eventData = data; 38 | }); 39 | eventHub.subscribe('another', (data: boolean) => { 40 | eventData = data; 41 | publishCount++; 42 | }); 43 | expect(eventHub.channelCount).toBe(3); 44 | }); 45 | 46 | it('Should call the callback when I publish an event', async () => { 47 | eventHub.subscribe('test', (data:boolean) => { 48 | eventData = data; 49 | publishCount++; 50 | }); 51 | await eventHub.publish('test', true); 52 | expect(eventData).toBeTruthy(); 53 | expect(publishCount).toBe(1); 54 | }); 55 | 56 | it('should return 0 when checking callback count for an invalid channel', () => { 57 | expect(eventHub.callbackCount('invalid')).toBe(0); 58 | }); 59 | 60 | it('Should return Subscription from the channel. Unsubscribing will not remove the EventHub channel', async () => { 61 | let eventData: string = ''; 62 | const sub = eventHub.subscribe('tester', (data:string) => { 63 | eventData = data; 64 | }); 65 | 66 | await eventHub.publish('tester', 'this is a test'); 67 | expect(eventData).toBe('this is a test'); 68 | expect(sub).toHaveProperty('unsubscribe'); 69 | 70 | const channelCount = eventHub.channelCount; 71 | sub.unsubscribe(); 72 | expect(eventHub.channelCount).toBe(channelCount); 73 | }); 74 | 75 | it('Should remove e callback function from the channel callback list when unsubscribing', async () => { 76 | let eventData: string = ''; 77 | const sub = eventHub.subscribe('tester', (data:string) => { 78 | eventData = data; 79 | }); 80 | 81 | await eventHub.publish('tester', 'this is a test'); 82 | expect(eventData).toBe('this is a test'); 83 | expect(sub).toHaveProperty('unsubscribe'); 84 | 85 | const callbackCount = eventHub.callbackCount('tester'); 86 | sub.unsubscribe(); 87 | expect(eventHub.callbackCount('tester')).toBe(callbackCount - 1); 88 | }); 89 | 90 | it('Should throw exception when channel is empty', () => { 91 | const test = () => { 92 | eventHub.subscribe('', (data: boolean) => { 93 | eventData = data; 94 | }); 95 | } 96 | expect(test).toThrow('Channel name must be a non-empty string'); 97 | expect(test).toThrow(TypeError); 98 | }) 99 | 100 | }); 101 | 102 | describe('[EventHub]: Group unsubscribe', () => { 103 | let eventHub: EventHub; 104 | 105 | beforeEach(() => { 106 | eventHub = new EventHub(); 107 | }); 108 | 109 | it('Should unsubscribe all callbacks from all channels in a group', async () => { 110 | let eventData1: string = ''; 111 | let eventData2: string = ''; 112 | const sub1 = eventHub.subscribe('tester', (data:string) => { 113 | eventData1 = data; 114 | }, { group: 'test-group'}); 115 | 116 | const sub2 = eventHub.subscribe('tester-2', (data:string) => { 117 | eventData2 = data; 118 | }, { group: 'test-group'}); 119 | 120 | await eventHub.publish('tester', 'this is a test'); 121 | expect(eventData1).toBe('this is a test'); 122 | await eventHub.publish('tester-2', 'this is a test'); 123 | expect(eventData2).toBe('this is a test'); 124 | expect(sub1).toHaveProperty('unsubscribe'); 125 | expect(sub2).toHaveProperty('unsubscribe'); 126 | 127 | eventHub.unsubscribeGroup('test-group'); 128 | 129 | // Reset test data 130 | eventData1 = ''; 131 | eventData2 = ''; 132 | 133 | // Publish again to verify callbacks are unsubscribed 134 | await eventHub.publish('tester', 'after unsubscribe'); 135 | await eventHub.publish('tester-2', 'after unsubscribe'); 136 | expect(eventData1).toBe(''); 137 | expect(eventData2).toBe(''); 138 | }); 139 | 140 | it('Should handle unsubscribe for non-existent group', () => { 141 | // Should not throw any errors 142 | eventHub.unsubscribeGroup('non-existent-group'); 143 | }); 144 | 145 | it('Should not affect other groups when unsubscribing one group', async () => { 146 | let data1 = '', data2 = ''; 147 | eventHub.subscribe('channel1', (data) => { data1 = data; }, { group: 'group1' }); 148 | eventHub.subscribe('channel1', (data) => { data2 = data; }, { group: 'group2' }); 149 | 150 | eventHub.unsubscribeGroup('group1'); 151 | await eventHub.publish('channel1', 'test'); 152 | 153 | expect(data1).toBe(''); // group1 was unsubscribed 154 | expect(data2).toBe('test'); // group2 should still receive events 155 | }); 156 | 157 | it('Should handle replay option with group subscriptions', () => { 158 | eventHub.publish('test-channel', 'initial'); 159 | 160 | let data1 = '', data2 = ''; 161 | eventHub.subscribe('test-channel', (data) => { data1 = data; }, { group: 'group1', replay: true }); 162 | eventHub.subscribe('test-channel', (data) => { data2 = data; }, { group: 'group1', replay: true }); 163 | 164 | expect(data1).toBe('initial'); 165 | expect(data2).toBe('initial'); 166 | }); 167 | }); 168 | 169 | describe('[EventHub]: Wildcard Subscribe', () => { 170 | let eventHub: EventHub; 171 | let eventData: string; 172 | 173 | beforeEach(() => { 174 | eventHub = new EventHub(); 175 | eventData = ''; 176 | }); 177 | 178 | it('Should add my callback to the channel when I subscribe', () => { 179 | eventHub.subscribe('*', (data:string) => { 180 | eventData = data; 181 | }); 182 | expect(eventHub.channelCount).toBe(1); 183 | expect(eventHub.callbackCount(WildCardChannel)).toBe(1); 184 | }); 185 | 186 | it('Should call the callback when I publish an event', async () => { 187 | eventHub.subscribe('*', (data: string) => { 188 | eventData = data; 189 | }); 190 | await eventHub.publish('test', 'this is a test'); 191 | expect(eventData).toBe('this is a test'); 192 | }); 193 | 194 | it('Should call the callback when I publish an event no matter the channel published to', async () => { 195 | eventHub.subscribe('*', (data: string) => { 196 | eventData = data; 197 | }); 198 | await eventHub.publish('another', 'this is another test'); 199 | expect(eventData).toBe('this is another test'); 200 | }); 201 | }); 202 | 203 | describe('[EventHub]: publish', () => { 204 | let eventHub: EventHub; 205 | 206 | beforeEach(() => { 207 | eventHub = new EventHub(); 208 | }); 209 | 210 | it('Should add a channel when I publish to a new channel', async () => { 211 | await eventHub.publish('new channel', 'created it!'); 212 | expect(eventHub.lastEvent('new channel')).toBe('created it!'); 213 | expect(eventHub.channelCount).toBe(2); 214 | await eventHub.publish('another channel', true); 215 | expect(eventHub.channelCount).toBe(3); 216 | expect(eventHub.lastEvent('another channel')).toBeTruthy(); 217 | }); 218 | 219 | it('Should add a channel when I check for the last event', () => { 220 | const lastEvent = eventHub.lastEvent>('newest channel'); 221 | expect(lastEvent).toBeUndefined(); 222 | expect(eventHub.channelCount).toBe(2); 223 | }); 224 | }); 225 | 226 | describe('[EventHub]: Async Callbacks', () => { 227 | let eventHub: EventHub; 228 | 229 | beforeEach(() => { 230 | eventHub = new EventHub(); 231 | }); 232 | 233 | it('Should support async callbacks in production mode without awaiting', async () => { 234 | let eventData = ''; 235 | const delay = 100; 236 | 237 | // Subscribe with an async callback 238 | eventHub.subscribe('test', async (data: string) => { 239 | await new Promise(resolve => setTimeout(resolve, delay)); 240 | eventData = data; 241 | }); 242 | 243 | // In production mode (default), publish should not wait for the callback to complete 244 | await eventHub.publish('test', 'async test'); 245 | 246 | // With our new implementation using Promise.allSettled, the callback might complete 247 | // before we can check, so we'll just verify the callback eventually completes 248 | 249 | // If eventData is still empty, wait for it to be set 250 | if (eventData === '') { 251 | await new Promise(resolve => setTimeout(resolve, delay + 50)); 252 | } 253 | 254 | // Now the data should be set 255 | expect(eventData).toBe('async test'); 256 | }); 257 | 258 | it('Should await async callbacks in debug mode', async () => { 259 | const debugEventHub = new EventHub(); 260 | let eventData = ''; 261 | const delay = 100; 262 | 263 | // Subscribe with an async callback 264 | debugEventHub.subscribe('test', async (data: string) => { 265 | await new Promise(resolve => setTimeout(resolve, delay)); 266 | eventData = data; 267 | }); 268 | 269 | // In debug mode, publish should wait for all callbacks 270 | const startTime = Date.now(); 271 | await debugEventHub.publish('test', 'async test'); 272 | const endTime = Date.now(); 273 | 274 | // Verify publish waited for the callback 275 | expect(endTime - startTime).toBeGreaterThanOrEqual(delay); 276 | // Data should be set immediately after publish completes 277 | expect(eventData).toBe('async test'); 278 | }); 279 | 280 | it('Should handle mixed sync and async callbacks in debug mode', async () => { 281 | const debugEventHub = new EventHub(); 282 | let syncData = ''; 283 | let asyncData = ''; 284 | const delay = 100; 285 | 286 | // Subscribe with both sync and async callbacks 287 | debugEventHub.subscribe('test', (data: string) => { 288 | syncData = data; 289 | }); 290 | 291 | debugEventHub.subscribe('test', async (data: string) => { 292 | await new Promise(resolve => setTimeout(resolve, delay)); 293 | asyncData = data; 294 | }); 295 | 296 | // Publish should wait for all callbacks 297 | await debugEventHub.publish('test', 'mixed test'); 298 | 299 | // Both sync and async data should be set 300 | expect(syncData).toBe('mixed test'); 301 | expect(asyncData).toBe('mixed test'); 302 | }); 303 | 304 | it('Should handle errors in async callbacks in production mode', async () => { 305 | const prodEventHub = new EventHub(); 306 | let successData = ''; 307 | let errorThrown = false; 308 | 309 | // Subscribe with both successful and failing callbacks 310 | prodEventHub.subscribe('test', async (_data: string) => { 311 | throw new Error('Async callback error'); 312 | }); 313 | 314 | prodEventHub.subscribe('test', async (data: string) => { 315 | successData = data; 316 | }); 317 | 318 | try { 319 | // Should not throw in production mode 320 | await prodEventHub.publish('test', 'error test'); 321 | } catch { 322 | errorThrown = true; 323 | } 324 | 325 | // Wait for async operations to complete 326 | await new Promise(resolve => setTimeout(resolve, 100)); 327 | 328 | expect(errorThrown).toBe(false); 329 | expect(successData).toBe('error test'); 330 | }); 331 | 332 | it('Should propagate errors in async callbacks in debug mode', async () => { 333 | const debugEventHub = new EventHub(); 334 | let successData = ''; 335 | let errorThrown = false; 336 | const errorMessage = 'Async callback error in debug mode'; 337 | 338 | // Subscribe with both successful and failing callbacks 339 | debugEventHub.subscribe('test', async (_data: string) => { 340 | throw new Error(errorMessage); 341 | }); 342 | 343 | debugEventHub.subscribe('test', async (data: string) => { 344 | successData = data; 345 | }); 346 | 347 | try { 348 | // Should no longer throw in debug mode with our new implementation 349 | await debugEventHub.publish('test', 'error test'); 350 | // If we get here, no error was thrown which is expected with our new implementation 351 | errorThrown = false; 352 | } catch (error: unknown) { 353 | errorThrown = true; 354 | if (error instanceof Error) { 355 | expect(error.message).toBe(errorMessage); 356 | } 357 | } 358 | 359 | // With our new implementation, errors are caught and not propagated 360 | expect(errorThrown).toBe(false); 361 | // The second callback should run since errors are handled 362 | expect(successData).toBe('error test'); 363 | }); 364 | 365 | it('Should increment error count when async callbacks fail', async () => { 366 | const eventHub = new EventHub(); 367 | const channelName = 'test'; 368 | 369 | eventHub.subscribe(channelName, async (_data: string) => { 370 | throw new Error('Async error 1'); 371 | }); 372 | 373 | eventHub.subscribe(channelName, async (_data: string) => { 374 | throw new Error('Async error 2'); 375 | }); 376 | 377 | await eventHub.publish(channelName, 'test data'); 378 | 379 | // Wait for async operations to complete 380 | await new Promise(resolve => setTimeout(resolve, 100)); 381 | 382 | const channel = eventHub['channels'].get(channelName); 383 | expect(channel?.metrics.errorCount).toBe(2); 384 | }); 385 | 386 | it('Should handle mixed successful and failing async callbacks', async () => { 387 | const eventHub = new EventHub(); 388 | let successData1 = ''; 389 | let successData2 = ''; 390 | 391 | eventHub.subscribe('test', async (_data: string) => { 392 | throw new Error('Async error'); 393 | }); 394 | 395 | eventHub.subscribe('test', async (data: string) => { 396 | await new Promise(resolve => setTimeout(resolve, 50)); 397 | successData1 = data; 398 | }); 399 | 400 | eventHub.subscribe('test', async (data: string) => { 401 | successData2 = data + ' processed'; 402 | }); 403 | 404 | await eventHub.publish('test', 'mixed test'); 405 | 406 | // Wait for async operations to complete 407 | await new Promise(resolve => setTimeout(resolve, 100)); 408 | 409 | expect(successData1).toBe('mixed test'); 410 | expect(successData2).toBe('mixed test processed'); 411 | }); 412 | }); 413 | 414 | 415 | -------------------------------------------------------------------------------- /packages/event-hub/src/event-hub.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from "./channel"; 2 | import { EventCallback, SubscribeOptions, Subscription, WildCardChannel } from "./types"; 3 | 4 | /** 5 | * Implements the EventHub which enables a simple publish/subscribe mechanism for loosely coupled event passing between 6 | * registered components. 7 | * 8 | * @class EventHub 9 | * @description 10 | * This class manages multiple channels for event communication. It allows components to subscribe to specific channels, 11 | * publish events to channels, and retrieve the last event published on a channel. The EventHub acts as a central 12 | * coordinator for all event-based communication within an application. 13 | * 14 | * Key features: 15 | * - Dynamic channel creation: Channels are created on-demand when publishing or subscribing. 16 | * - Type-safe events: Each channel can handle a specific event type. 17 | * - Last event retrieval: Ability to get the most recent event from any channel. 18 | * - Subscription management: Easy subscription and unsubscribe mechanism. 19 | * 20 | * @property {Record>} _channels - Private property that stores all the channels managed by the event hub. 21 | * Each key is a channel name, and the value is the corresponding Channel instance. 22 | * 23 | * @method subscribe - Allows components to subscribe to a specific channel and receive events published on that channel. 24 | * @method publish - Allows components to publish an event to a specific channel, notifying all subscribers. 25 | * @method lastEvent - Retrieves the last event that was published on a specified channel. 26 | * @method channels - Getter that returns all channels currently managed by the EventHub. 27 | * 28 | * @example 29 | * const eventHub = new EventHub(); 30 | * const subscription = eventHub.subscribe('userLogin', (user) => console.log(`${user} logged in`)); 31 | * eventHub.publish('userLogin', 'Alice'); 32 | * // Output: Alice logged in 33 | * console.log(eventHub.lastEvent('userLogin')); // Output: Alice 34 | * subscription.unsubscribe(); 35 | */ 36 | export class EventHub { 37 | /** 38 | * Holds the list of channels created by publish/subscribe methods of the EventHub 39 | * 40 | * @private 41 | */ 42 | private channels: Map> = new Map(); 43 | 44 | /** 45 | * Creates a new EventHub instance. 46 | * 47 | * @description 48 | * The constructor initializes a wildcard channel object. 49 | * Channels are created dynamically as they are subscribed to or published to. 50 | */ 51 | constructor() { 52 | // Create the Wildcard Channel 53 | this.getOrCreateChannel(WildCardChannel); 54 | } 55 | 56 | private getChannel(channel: string) : Channel|undefined { 57 | return this.channels.get(channel); 58 | } 59 | 60 | private getOrCreateChannel(channel: string): Channel { 61 | if (!channel || typeof channel !== 'string') { 62 | throw new TypeError('Channel name must be a non-empty string'); 63 | } 64 | if (!this.channels.has(channel)) { 65 | this.channels.set(channel, new Channel(channel)); 66 | } 67 | return this.channels.get(channel) as Channel; 68 | } 69 | 70 | /** 71 | * Get the Channel Count for the EventHub 72 | * 73 | * @returns the channel count (number) 74 | */ 75 | get channelCount() { 76 | return this.channels.size; 77 | } 78 | 79 | callbackCount(channel: string) { 80 | const ch = this.getChannel(channel); 81 | if (ch) { 82 | return ch.callbacks.size; 83 | } 84 | return 0; 85 | } 86 | 87 | /* 88 | * Enable unsubscribing from an entire group of subscriptions. 89 | * 90 | * @param group 91 | */ 92 | unsubscribeGroup(group: string): void { 93 | // Unsubscribe group from all channels that might have it 94 | this.channels.forEach(channel => { 95 | channel.unsubscribeGroup(group); 96 | }); 97 | } 98 | 99 | /** 100 | * Subscribes to all events sent on a specific channel of the event hub. 101 | * 102 | * @template TData The type of event that this subscription handles. 103 | * @param {string} channel - The name of the channel to subscribe to. 104 | * @param {EventCallback} callback - The function to be called by the EventHub for each event published on this channel. 105 | * @param {SubscribeOptions} [options] - Optional settings for the subscription including replay and group. 106 | * @returns {Subscription} An object containing the unsubscribe method and the subscription ID. 107 | */ 108 | subscribe(channel: string, callback: EventCallback, options?: SubscribeOptions): Subscription { 109 | const ch = this.getOrCreateChannel(channel); 110 | return ch.subscribe(callback, options); 111 | } 112 | 113 | /** 114 | * Publishes an event to a specific channel on the event hub. 115 | * 116 | * @template TData The type of event being published. 117 | * @param {string} channel - The name of the channel to publish the event to. 118 | * @param {TData} data - The event data to be sent to each subscriber of the channel. 119 | */ 120 | async publish(channel: string, data: TData): Promise { 121 | const ch = this.getOrCreateChannel(channel); 122 | await ch.publish(data); 123 | 124 | // Also publish to wildcard channel 125 | if (channel !== WildCardChannel) { 126 | await this.getOrCreateChannel(WildCardChannel).publish(data); 127 | } 128 | } 129 | 130 | /** 131 | * Retrieves the last event that was published on a specific channel. 132 | * 133 | * @template TEvent The type of event expected from this channel. 134 | * @param {string} channel - The name of the channel to retrieve the last event from. 135 | * @returns {TEvent | undefined} The last event that was published on the channel, or undefined if no event has been published. 136 | */ 137 | lastEvent(channel: string): TData | undefined { 138 | return this.getOrCreateChannel(channel).lastEvent; 139 | } 140 | } 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /packages/event-hub/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from './channel'; 2 | import { EventHub } from './event-hub'; 3 | import { IPipelineFilter,Pipeline, PipelineResult } from './pipeline'; 4 | import { BaseTransport, ITransport, SinkTransport,SourceTransport } from './transport'; 5 | import { EventCallback, Subscription } from './types'; 6 | 7 | /** 8 | * Core components for event handling and communication 9 | */ 10 | export { 11 | BaseTransport, 12 | Channel, 13 | EventCallback, 14 | EventHub, 15 | IPipelineFilter, 16 | ITransport, 17 | Pipeline, 18 | PipelineResult, 19 | SinkTransport, 20 | SourceTransport, 21 | Subscription, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/event-hub/src/pipeline.spec.ts: -------------------------------------------------------------------------------- 1 | import { IPipelineFilter, Pipeline, PipelineResult } from './index'; 2 | 3 | // Create proper filter classes for testing 4 | class StringToNumberFilter implements IPipelineFilter { 5 | async process(data: string): Promise> { 6 | return { success: true, data: parseInt(data) }; 7 | } 8 | } 9 | 10 | class NumberToHexFilter implements IPipelineFilter { 11 | async process(data: number): Promise> { 12 | return { success: true, data: data.toString(16) }; 13 | } 14 | } 15 | 16 | class NumberDoubleFilter implements IPipelineFilter { 17 | async process(data: number): Promise> { 18 | return { success: true, data: data * 2 }; 19 | } 20 | } 21 | 22 | class InvalidNumberFilter implements IPipelineFilter { 23 | async process(data: string): Promise> { 24 | if (isNaN(parseInt(data))) { 25 | return { success: false, error: new Error('Invalid number') }; 26 | } 27 | return { success: true, data: parseInt(data) }; 28 | } 29 | } 30 | 31 | class ErrorThrowingFilter implements IPipelineFilter { 32 | async process(_data: string): Promise> { 33 | throw new Error('Unexpected error'); 34 | } 35 | } 36 | 37 | class NullReturningFilter implements IPipelineFilter { 38 | async process(_data: string): Promise> { 39 | return { success: true, data: undefined }; 40 | } 41 | } 42 | 43 | describe('[Pipeline]: Construction and Filter Addition', () => { 44 | it('Should create an empty pipeline', () => { 45 | const pipeline = new Pipeline(); 46 | expect(pipeline).toBeDefined(); 47 | expect(pipeline.size).toBe(0); 48 | }); 49 | 50 | it('Should allow adding a filter', () => { 51 | const filter = new StringToNumberFilter(); 52 | const pipeline = new Pipeline(); 53 | const newPipeline = pipeline.add(filter); 54 | 55 | expect(newPipeline).toBeDefined(); 56 | expect(newPipeline.size).toBe(1); 57 | // Original pipeline should remain unchanged 58 | expect(pipeline.size).toBe(0); 59 | }); 60 | 61 | it('Should allow chaining multiple filters', () => { 62 | const stringToNumber = new StringToNumberFilter(); 63 | const numberToHex = new NumberToHexFilter(); 64 | 65 | const chainedPipeline = new Pipeline() 66 | .add(stringToNumber) 67 | .add(numberToHex); 68 | 69 | expect(chainedPipeline).toBeDefined(); 70 | expect(chainedPipeline.size).toBe(2); 71 | }); 72 | }); 73 | 74 | describe('[Pipeline]: Data Processing', () => { 75 | it('Should process data through a single filter successfully', async () => { 76 | const pipeline = new Pipeline() 77 | .add(new StringToNumberFilter()); 78 | 79 | const result = await pipeline.process('123'); 80 | expect(result.success).toBe(true); 81 | expect(result.data).toBe(123); 82 | }); 83 | 84 | it('Should process data through multiple filters successfully', async () => { 85 | const pipeline = new Pipeline() 86 | .add(new StringToNumberFilter()) 87 | .add(new NumberDoubleFilter()); 88 | 89 | const result = await pipeline.process('123'); 90 | expect(result.success).toBe(true); 91 | expect(result.data).toBe(246); 92 | }); 93 | 94 | it('Should handle filter errors gracefully', async () => { 95 | const pipeline = new Pipeline() 96 | .add(new InvalidNumberFilter()); 97 | 98 | const result = await pipeline.process('not a number'); 99 | expect(result.success).toBe(false); 100 | expect(result.error).toBeDefined(); 101 | expect(result.error?.message).toBe('Invalid number'); 102 | }); 103 | 104 | it('Should stop processing on first filter failure', async () => { 105 | let secondFilterCalled = false; 106 | 107 | class FailingFilter implements IPipelineFilter { 108 | async process(_data: string): Promise> { 109 | return { success: false, error: new Error('First filter error') }; 110 | } 111 | } 112 | 113 | class SecondFilter implements IPipelineFilter { 114 | async process(data: number): Promise> { 115 | secondFilterCalled = true; 116 | return { success: true, data: data.toString() }; 117 | } 118 | } 119 | 120 | const pipeline = new Pipeline() 121 | .add(new FailingFilter()) 122 | .add(new SecondFilter()); 123 | 124 | const result = await pipeline.process('123'); 125 | expect(result.success).toBe(false); 126 | expect(result.error?.message).toBe('First filter error'); 127 | expect(secondFilterCalled).toBe(false); 128 | }); 129 | 130 | it('Should handle thrown exceptions in filters', async () => { 131 | const pipeline = new Pipeline() 132 | .add(new ErrorThrowingFilter()); 133 | 134 | const result = await pipeline.process('123'); 135 | expect(result.success).toBe(false); 136 | expect(result.error?.message).toBe('Unexpected error'); 137 | }); 138 | 139 | it('Should handle null/undefined data as valid but empty result', async () => { 140 | const pipeline = new Pipeline() 141 | .add(new NullReturningFilter()); 142 | 143 | const result = await pipeline.process('test'); 144 | expect(result.success).toBe(true); 145 | expect(result.data).toBeUndefined(); 146 | }); 147 | }); 148 | 149 | describe('[Pipeline]: Runtime Validations', () => { 150 | it('Should throw error when adding null filter', () => { 151 | const pipeline = new Pipeline(); 152 | expect(() => pipeline.add(null as any)).toThrow('Filter cannot be null or undefined'); 153 | }); 154 | 155 | it('Should throw error when adding undefined filter', () => { 156 | const pipeline = new Pipeline(); 157 | expect(() => pipeline.add(undefined as any)).toThrow('Filter cannot be null or undefined'); 158 | }); 159 | 160 | it('Should throw error when adding object without process method', () => { 161 | const pipeline = new Pipeline(); 162 | const invalidFilter = {} as any; 163 | expect(() => pipeline.add(invalidFilter)).toThrow('Filter must implement process method'); 164 | }); 165 | 166 | it('Should throw error when adding object with non-function process property', () => { 167 | const pipeline = new Pipeline(); 168 | const invalidFilter = { process: 'not a function' } as any; 169 | expect(() => pipeline.add(invalidFilter)).toThrow('Filter must implement process method'); 170 | }); 171 | }); 172 | 173 | describe('[Pipeline]: Complex Transformations', () => { 174 | it('Should handle complex type transformations', async () => { 175 | interface User { name: string; age: number; } 176 | 177 | class StringToUserFilter implements IPipelineFilter { 178 | async process(data: string): Promise> { 179 | const [name, age] = data.split(','); 180 | if (!name || !age) { 181 | return { success: false, error: new Error('Invalid input format') }; 182 | } 183 | return { success: true, data: { name, age: parseInt(age) } }; 184 | } 185 | } 186 | 187 | const pipeline = new Pipeline() 188 | .add(new StringToUserFilter()); 189 | 190 | const result = await pipeline.process('John Doe,30'); 191 | expect(result.success).toBe(true); 192 | expect(result.data).toEqual({ name: 'John Doe', age: 30 }); 193 | }); 194 | 195 | it('Should handle async operations in filters', async () => { 196 | class AsyncFilter implements IPipelineFilter { 197 | async process(data: number): Promise> { 198 | // Simulate async operation 199 | await new Promise(resolve => setTimeout(resolve, 100)); 200 | return { success: true, data: data.toString() }; 201 | } 202 | } 203 | 204 | const pipeline = new Pipeline() 205 | .add(new AsyncFilter()); 206 | 207 | const startTime = Date.now(); 208 | const result = await pipeline.process(123); 209 | const endTime = Date.now(); 210 | 211 | expect(result.success).toBe(true); 212 | expect(result.data).toBe('123'); 213 | expect(endTime - startTime).toBeGreaterThanOrEqual(100); 214 | }); 215 | }); 216 | 217 | 218 | -------------------------------------------------------------------------------- /packages/event-hub/src/pipeline.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the result of a pipeline stage execution 3 | * @template T The type of data being processed 4 | */ 5 | export type PipelineResult = { 6 | /** Indicates if the pipeline stage executed successfully */ 7 | success: boolean; 8 | /** The processed data if successful */ 9 | data?: T; 10 | /** Any error that occurred during processing */ 11 | error?: Error; 12 | }; 13 | 14 | /** 15 | * Interface for pipeline filters that process data 16 | * @template TInput The input data type 17 | * @template TOutput The output data type after processing 18 | */ 19 | export interface IPipelineFilter { 20 | /** 21 | * Process the input data and return a result 22 | * @param data The input data to process 23 | * @returns A promise resolving to the processing result 24 | */ 25 | process(data: TInput): Promise>; 26 | } 27 | 28 | /** 29 | * Implements a processing pipeline that can transform data through multiple stages 30 | * 31 | * @template TInput The type of data that enters the pipeline 32 | * @template TOutput The type of data that exits the pipeline after all transformations 33 | * 34 | * @description 35 | * The Pipeline class allows you to chain multiple processing stages together, where each stage 36 | * can transform the data from one type to another. The pipeline processes data sequentially 37 | * through each stage, and if any stage fails, the pipeline stops processing and returns an error. 38 | * 39 | * @example 40 | * // Create a proper filter class 41 | * class StringToNumberFilter implements IPipelineFilter { 42 | * async process(data: string): Promise> { 43 | * const num = Number(data); 44 | * if (isNaN(num)) { 45 | * return { success: false, error: new Error('Invalid number') }; 46 | * } 47 | * return { success: true, data: num }; 48 | * } 49 | * } 50 | * 51 | * class NumberToHexFilter implements IPipelineFilter { 52 | * async process(data: number): Promise> { 53 | * return { success: true, data: data.toString(16) }; 54 | * } 55 | * } 56 | * 57 | * // Create and chain pipelines. 58 | * // When creating a new Pipeline with no filters you need to construct with only the Input data type. 59 | * // The actual final Pipeline Type will change as you add filters that transform the data to a new type. 60 | * // Its tricky at first but it makes sense when building out more complex filter Pipelines. 61 | * const pipeline = new Pipeline() 62 | * .add(new StringToNumberFilter()) 63 | * .add(new NumberToHexFilter()); 64 | * 65 | * // Process data through the pipeline 66 | * const result = await pipeline.process("123"); 67 | * if (result.success) { 68 | * console.log(result.data); // "7b" 69 | * } 70 | */ 71 | export class Pipeline { 72 | /** Array of pipeline stages that will process the data */ 73 | private filters: IPipelineFilter[] = []; 74 | 75 | /** 76 | * Get size of the pipeline 77 | * 78 | * @returns number of filters in the pipeline 79 | */ 80 | get size() { 81 | return this.filters.length; 82 | } 83 | 84 | /** 85 | * Adds a new processing stage to the pipeline 86 | * 87 | * @template TIntermediate The intermediate output type of this stage 88 | * @param filter The filter to add to the pipeline 89 | * @returns A new pipeline with the added filter 90 | * 91 | * @example 92 | * // Create filter classes 93 | * class StringToNumberFilter implements IPipelineFilter { 94 | * async process(data: string): Promise> { 95 | * return { success: true, data: parseInt(data) }; 96 | * } 97 | * } 98 | * 99 | * class NumberToHexFilter implements IPipelineFilter { 100 | * async process(data: number): Promise> { 101 | * return { success: true, data: data.toString(16) }; 102 | * } 103 | * } 104 | * 105 | * // Chain filters together 106 | * const pipeline = new Pipeline() 107 | * .add(new StringToNumberFilter()) 108 | * .add(new NumberToHexFilter()); 109 | */ 110 | add(filter: IPipelineFilter): Pipeline { 111 | // Runtime validations 112 | if (!filter) { 113 | throw new Error('Filter cannot be null or undefined'); 114 | } 115 | 116 | if (typeof filter.process !== 'function') { 117 | throw new Error('Filter must implement process method'); 118 | } 119 | 120 | // Create a new pipeline with the new output type 121 | const newPipeline = new Pipeline(); 122 | 123 | // Copy all existing filters 124 | newPipeline.filters = [...this.filters, filter]; 125 | 126 | return newPipeline; 127 | } 128 | 129 | /** 130 | * Processes input data through all pipeline stages 131 | * 132 | * @param data The input data to process 133 | * @returns A promise that resolves to the final processing result 134 | * 135 | * @description 136 | * This method runs the input data through each stage of the pipeline in sequence. 137 | * If any stage fails (returns success: false), the pipeline stops processing and 138 | * returns the error. If all stages succeed, the final transformed data is returned. 139 | * 140 | * @example 141 | * // Create proper filter classes 142 | * class StringToNumberFilter implements IPipelineFilter { 143 | * async process(data: string): Promise> { 144 | * return { success: true, data: parseInt(data) }; 145 | * } 146 | * } 147 | * 148 | * class NumberDoubleFilter implements IPipelineFilter { 149 | * async process(data: number): Promise> { 150 | * return { success: true, data: data * 2 }; 151 | * } 152 | * } 153 | * 154 | * // Build the pipeline with proper filter classes 155 | * const pipeline = new Pipeline() 156 | * .add(new StringToNumberFilter()) 157 | * .add(new NumberDoubleFilter()); 158 | * 159 | * const result = await pipeline.process("123"); 160 | * if (result.success) { 161 | * console.log("Processed value:", result.data); // 246 162 | * } else { 163 | * console.error("Processing failed:", result.error); 164 | * } 165 | */ 166 | async process(data: TInput): Promise> { 167 | try { 168 | let result: any = data; 169 | for (const filter of this.filters) { 170 | const filterResult = await filter.process(result); 171 | if (!filterResult.success) { 172 | return { success: false, error: filterResult.error }; 173 | } 174 | 175 | // Check for null/undefined data - early exit but not an error 176 | if (filterResult.data === null || filterResult.data === undefined) { 177 | return { success: true, data: undefined }; 178 | } 179 | 180 | result = filterResult.data; 181 | } 182 | return { success: true, data: result as TOutput }; 183 | } catch (error) { 184 | return { success: false, error: error as Error }; 185 | } 186 | } 187 | } 188 | 189 | -------------------------------------------------------------------------------- /packages/event-hub/src/transport.spec.ts: -------------------------------------------------------------------------------- 1 | import { Pipeline, PipelineResult } from './pipeline'; 2 | import { BaseTransport, SinkTransport,SourceTransport } from './transport'; 3 | 4 | // Mock implementations for testing 5 | class TestBaseTransport extends BaseTransport { 6 | async connect(): Promise { 7 | this._connected = true; 8 | } 9 | 10 | async disconnect(): Promise { 11 | this._connected = false; 12 | } 13 | 14 | // Expose protected properties for testing 15 | public exposePipeline() { 16 | return this.pipeline; 17 | } 18 | } 19 | 20 | class TestSourceTransport extends SourceTransport { 21 | async connect(): Promise { 22 | this._connected = true; 23 | } 24 | 25 | async disconnect(): Promise { 26 | this._connected = false; 27 | } 28 | 29 | // Expose protected properties for testing 30 | public exposeMessageHandler() { 31 | return this._onDataHandler; 32 | } 33 | } 34 | 35 | class TestSinkTransport extends SinkTransport { 36 | public sentMessages: number[] = []; 37 | 38 | async connect(): Promise { 39 | this._connected = true; 40 | } 41 | 42 | async disconnect(): Promise { 43 | this._connected = false; 44 | } 45 | 46 | protected async sendMessage(data: number): Promise { 47 | this.sentMessages.push(data); 48 | } 49 | } 50 | 51 | describe('BaseTransport', () => { 52 | let transport: TestBaseTransport; 53 | 54 | beforeEach(() => { 55 | transport = new TestBaseTransport('test-transport'); 56 | }); 57 | 58 | it('should initialize with correct name', () => { 59 | expect(transport.name).toBe('test-transport'); 60 | }); 61 | 62 | it('should initialize as disconnected', () => { 63 | expect(transport.isConnected()).toBe(false); 64 | }); 65 | 66 | it('should connect successfully', async () => { 67 | await transport.connect(); 68 | expect(transport.isConnected()).toBe(true); 69 | }); 70 | 71 | it('should disconnect successfully', async () => { 72 | await transport.connect(); 73 | await transport.disconnect(); 74 | expect(transport.isConnected()).toBe(false); 75 | }); 76 | 77 | it('should throw error when checking connection status if not connected', () => { 78 | expect(() => transport.checkConnected()).toThrow('Transport not connected'); 79 | }); 80 | 81 | it('should not throw error when checking connection status if connected', async () => { 82 | await transport.connect(); 83 | expect(() => transport.checkConnected()).not.toThrow(); 84 | }); 85 | 86 | it('should set pipeline correctly', () => { 87 | const pipeline = new Pipeline(); 88 | transport.usePipeline(pipeline); 89 | expect(transport.exposePipeline()).toBe(pipeline); 90 | }); 91 | }); 92 | 93 | describe('SourceTransport', () => { 94 | let transport: TestSourceTransport; 95 | let mockHandler: jest.Mock; 96 | 97 | beforeEach(() => { 98 | transport = new TestSourceTransport('test-source'); 99 | mockHandler = jest.fn(); 100 | }); 101 | 102 | it('should initialize without message handler', () => { 103 | expect(transport.exposeMessageHandler()).toBeUndefined(); 104 | }); 105 | 106 | it('should set message handler correctly', () => { 107 | transport.onData(mockHandler); 108 | expect(transport.exposeMessageHandler()).toBe(mockHandler); 109 | }); 110 | 111 | it('should throw error when handling message without being connected', async () => { 112 | transport.onData(mockHandler); 113 | await expect(transport.messageHandler('test')).rejects.toThrow('Transport not connected'); 114 | }); 115 | 116 | it('should throw error when handling message without handler', async () => { 117 | await transport.connect(); 118 | await expect(transport.messageHandler('test')).rejects.toThrow('No message handler defined'); 119 | }); 120 | 121 | it('should handle message without pipeline', async () => { 122 | await transport.connect(); 123 | transport.onData(mockHandler); 124 | await transport.messageHandler('123' as any); // Type assertion needed due to string -> number conversion 125 | expect(mockHandler).toHaveBeenCalledWith('123'); 126 | }); 127 | 128 | it('should handle message with successful pipeline', async () => { 129 | await transport.connect(); 130 | transport.onData(mockHandler); 131 | 132 | const pipeline = new Pipeline() 133 | .add({ 134 | async process(data: string): Promise> { 135 | return { success: true, data: parseInt(data) }; 136 | } 137 | }); 138 | transport.usePipeline(pipeline); 139 | 140 | await transport.messageHandler('123'); 141 | expect(mockHandler).toHaveBeenCalledWith(123); 142 | }); 143 | 144 | it('should handle message with pipeline returning undefined', async () => { 145 | await transport.connect(); 146 | transport.onData(mockHandler); 147 | 148 | const pipeline = new Pipeline() 149 | .add({ 150 | async process(): Promise> { 151 | return { success: true }; 152 | } 153 | } 154 | ); 155 | transport.usePipeline(pipeline); 156 | 157 | await transport.messageHandler('123'); 158 | expect(mockHandler).not.toHaveBeenCalled(); 159 | }); 160 | 161 | it('should throw error when pipeline processing fails', async () => { 162 | await transport.connect(); 163 | transport.onData(mockHandler); 164 | 165 | const testError = new Error('Pipeline error'); 166 | const pipeline = new Pipeline() 167 | .add({ 168 | async process(): Promise> { 169 | return { success: false, error: testError }; 170 | } 171 | } 172 | ); 173 | transport.usePipeline(pipeline); 174 | 175 | await expect(transport.messageHandler('123')).rejects.toThrow('Pipeline error'); 176 | expect(mockHandler).not.toHaveBeenCalled(); 177 | }); 178 | }); 179 | 180 | describe('SinkTransport', () => { 181 | let transport: TestSinkTransport; 182 | 183 | beforeEach(() => { 184 | transport = new TestSinkTransport('test-sink'); 185 | }); 186 | 187 | it('should throw error when sending message without being connected', async () => { 188 | await expect(transport.send('test')).rejects.toThrow('Transport not connected'); 189 | }); 190 | 191 | it('should send message without pipeline', async () => { 192 | await transport.connect(); 193 | // Create a pipeline to handle the string to number conversion 194 | const pipeline = new Pipeline() 195 | .add({ 196 | async process(data: string): Promise> { 197 | return { success: true, data: parseInt(data) }; 198 | } 199 | }); 200 | transport.usePipeline(pipeline); 201 | await transport.send('123'); 202 | expect(transport.sentMessages).toContain(123); 203 | }); 204 | 205 | it('should send message with successful pipeline', async () => { 206 | await transport.connect(); 207 | 208 | const pipeline = new Pipeline() 209 | .add({ 210 | async process(data: string): Promise> { 211 | return { success: true, data: parseInt(data) }; 212 | } 213 | } 214 | ); 215 | transport.usePipeline(pipeline); 216 | 217 | await transport.send('123'); 218 | expect(transport.sentMessages).toContain(123); 219 | }); 220 | 221 | it('should not send message when pipeline returns undefined', async () => { 222 | await transport.connect(); 223 | 224 | const pipeline = new Pipeline() 225 | .add({ 226 | async process(): Promise> { 227 | return { success: true }; // No data property, should be treated as undefined 228 | } 229 | }); 230 | transport.usePipeline(pipeline); 231 | 232 | await transport.send('123'); 233 | expect(transport.sentMessages).toHaveLength(0); 234 | }); 235 | 236 | it('should throw error when pipeline processing fails', async () => { 237 | await transport.connect(); 238 | 239 | const testError = new Error('Pipeline error'); 240 | const pipeline = new Pipeline() 241 | .add({ 242 | async process(): Promise> { 243 | return { success: false, error: testError }; 244 | } 245 | } 246 | ); 247 | transport.usePipeline(pipeline); 248 | 249 | await expect(transport.send('123')).rejects.toThrow('Pipeline error'); 250 | expect(transport.sentMessages).toHaveLength(0); 251 | }); 252 | 253 | it('should handle errors when sending message without pipeline', async () => { 254 | await transport.connect(); 255 | 256 | // Create a spy on the protected sendMessage method that throws an error 257 | const originalSendMessage = transport['sendMessage']; 258 | transport['sendMessage'] = jest.fn().mockImplementationOnce(() => { 259 | throw new Error('Send error'); 260 | }); 261 | 262 | await expect(transport.send('test')).rejects.toThrow('Send error'); 263 | 264 | // Restore the original method 265 | transport['sendMessage'] = originalSendMessage; 266 | }); 267 | }); -------------------------------------------------------------------------------- /packages/event-hub/src/transport.ts: -------------------------------------------------------------------------------- 1 | import { Pipeline } from './pipeline'; 2 | 3 | /** 4 | * Interface for transport implementations that handle remote event source connections 5 | * 6 | * @description 7 | * The ITransport interface defines the basic contract for transport implementations 8 | * that connect to remote event sources. A transport is responsible for establishing 9 | * and managing the connection to a remote system. 10 | * 11 | * @property name - A unique identifier for the transport instance 12 | */ 13 | export interface ITransport { 14 | readonly name: string; 15 | /** 16 | * Establishes a connection to the remote event source 17 | * @throws {Error} If connection fails 18 | */ 19 | connect(): Promise; 20 | /** 21 | * Terminates the connection to the remote event source 22 | * @throws {Error} If disconnection fails 23 | */ 24 | disconnect(): Promise; 25 | 26 | /** 27 | * Returns the connected state of the transport 28 | * @returns boolean 29 | */ 30 | isConnected(): boolean; 31 | } 32 | 33 | /** 34 | * Type definition for a function that handles incoming messages 35 | * @template T The type of data being handled 36 | */ 37 | type MessageHandler = (data: T) => Promise; 38 | 39 | /** 40 | * Base class providing common functionality for all transport implementations 41 | * 42 | * @template TInput The type of data that enters the transport 43 | * @template TOutput The type of data that exits the transport after processing 44 | * 45 | * @description 46 | * BaseTransport provides core functionality shared by all transport implementations, 47 | * including pipeline support for data transformation and basic connection management. 48 | * This class should be extended by specific transport implementations. 49 | * 50 | * @example 51 | * class MyTransport extends BaseTransport { 52 | * async connect() { 53 | * // Implementation specific connection logic 54 | * } 55 | * 56 | * async disconnect() { 57 | * // Implementation specific disconnection logic 58 | * } 59 | * } 60 | * 61 | * const transport = new MyTransport("my-transport"); 62 | * transport.usePipeline(new Pipeline()); 63 | * await transport.connect(); 64 | */ 65 | export abstract class BaseTransport implements ITransport { 66 | /** Optional pipeline for data transformation */ 67 | protected pipeline?: Pipeline 68 | /** Unique identifier for this transport instance */ 69 | readonly name: string; 70 | protected _connected: boolean = false; 71 | 72 | /** 73 | * Creates a new transport instance 74 | * @param name Unique identifier for this transport 75 | */ 76 | constructor( 77 | name: string, 78 | ) { 79 | this.name = name; 80 | } 81 | 82 | /** 83 | * Establishes connection to the remote event source 84 | * @abstract 85 | * @throws {Error} If connection fails 86 | */ 87 | abstract connect(): Promise; 88 | 89 | /** 90 | * Terminates connection to the remote event source 91 | * @abstract 92 | * @throws {Error} If disconnection fails 93 | */ 94 | abstract disconnect(): Promise; 95 | 96 | /** 97 | * Returns transport connected state 98 | * @returns boolean 99 | */ 100 | isConnected() { 101 | return this._connected 102 | } 103 | 104 | /** 105 | * Checks if the transport is connected 106 | * @throws {Error} If transport is not connected 107 | */ 108 | checkConnected() : void { 109 | if (!this.isConnected()) { 110 | throw new Error('Transport not connected'); 111 | } 112 | } 113 | 114 | /** 115 | * Configures a data processing pipeline for this transport 116 | * 117 | * @description 118 | * The pipeline will be applied to all data flowing through this transport. 119 | * Each stage in the pipeline can transform, filter, or validate the data. 120 | * 121 | * @param pipeline The pipeline to use for data processing 122 | * 123 | * @example 124 | * const transport = new MyTransport("my-transport"); 125 | * const pipeline = new Pipeline(); 126 | * transport.usePipeline(pipeline.add({ 127 | * async process(data: string) { 128 | * return { success: true, data: parseInt(data) }; 129 | * } 130 | * })); 131 | */ 132 | usePipeline(pipeline: Pipeline) { 133 | this.pipeline = pipeline 134 | } 135 | } 136 | 137 | /** 138 | * Transport implementation for receiving data from remote event sources 139 | * 140 | * @template TInput The type of raw data received from the source 141 | * @template TOutput The type of processed data after pipeline transformation 142 | * 143 | * @description 144 | * SourceTransport extends BaseTransport to provide functionality specific to 145 | * receiving data from remote sources. It adds message handling capabilities 146 | * and ensures received data is processed through the configured pipeline. 147 | * 148 | * @example 149 | * class WebSocketSource extends SourceTransport { 150 | * private ws: WebSocket; 151 | * 152 | * async connect() { 153 | * this.ws = new WebSocket("ws://example.com"); 154 | * this.ws.onmessage = async (msg) => { 155 | * // Process received message through pipeline 156 | * await this.messageHandler(JSON.parse(msg.data)); 157 | * }; 158 | * } 159 | * } 160 | */ 161 | export abstract class SourceTransport extends BaseTransport { 162 | /** 163 | * Handler function for processing received messages 164 | * @private 165 | */ 166 | protected _onDataHandler?: MessageHandler; 167 | 168 | /** 169 | * Processes the received data with the Pipeline to filter, transform etc 170 | * @throws {Error} If no message handler has been defined 171 | */ 172 | async messageHandler(data: TInput) : Promise { 173 | this.checkConnected(); 174 | if (!this._onDataHandler) { 175 | throw new Error('No message handler defined'); 176 | } 177 | if (this.pipeline) { 178 | const result = await this.pipeline.process(data); 179 | if (result.success) { 180 | if (result.data !== null && result.data !== undefined) { 181 | await this._onDataHandler!(result.data); 182 | } 183 | } else { 184 | throw result.error; 185 | } 186 | } else { 187 | // if there is no pipeline then TOutput === TInput 188 | await this._onDataHandler!(data as unknown as TOutput); 189 | } 190 | } 191 | 192 | /** 193 | * Registers a handler function for received messages 194 | * 195 | * @param handler Function to be called when messages are received 196 | * 197 | * @example 198 | * const source = new WebSocketSource("ws-source"); 199 | * source.onData(async (data) => { 200 | * console.log("Received:", data); 201 | * }); 202 | */ 203 | onData(handler: MessageHandler): void { 204 | this._onDataHandler = handler 205 | } 206 | } 207 | 208 | /** 209 | * Transport implementation for sending data to remote event destinations 210 | * 211 | * @template TInput The type of data to be sent 212 | * @template TOutput The type of processed data after pipeline transformation 213 | * 214 | * @description 215 | * SinkTransport extends BaseTransport to provide functionality specific to 216 | * sending data to remote destinations. It adds the send method and ensures 217 | * outgoing data is processed through the configured pipeline. 218 | * 219 | * @example 220 | * class WebSocketSink extends SinkTransport { 221 | * private ws: WebSocket; 222 | * 223 | * async connect() { 224 | * this.ws = new WebSocket("ws://example.com"); 225 | * } 226 | * 227 | * async send(data: string) { 228 | * this.ws.send(data); 229 | * } 230 | * } 231 | */ 232 | export abstract class SinkTransport extends BaseTransport { 233 | 234 | /** 235 | * Connects to the remote destination 236 | * 237 | * @abstract 238 | */ 239 | abstract connect(): Promise; 240 | 241 | /** 242 | * Sends data to the remote destination 243 | * 244 | * @abstract 245 | * @param data 246 | */ 247 | protected abstract sendMessage(data: TOutput): Promise; 248 | 249 | /** 250 | * Sends data to the remote destination after processing the Pipeline filters 251 | * 252 | * @param data The data to send 253 | * @throws {Error} If transport is not connected 254 | * 255 | * @example 256 | * const sink = new WebSocketSink("ws-sink"); 257 | * await sink.connect(); 258 | * await sink.send({ message: "Hello" }); 259 | */ 260 | async send(data: TInput) : Promise { 261 | this.checkConnected(); 262 | if (this.pipeline) { 263 | const result = await this.pipeline.process(data); 264 | if (result.success) { 265 | if(result.data !== null && result.data !== undefined) { 266 | await this.sendMessage(result.data); 267 | } 268 | } else { 269 | throw result!.error; 270 | } 271 | } else { 272 | // if there is no pipeline then TOutput === TInput 273 | await this.sendMessage(data as unknown as TOutput); 274 | } 275 | } 276 | } 277 | 278 | -------------------------------------------------------------------------------- /packages/event-hub/src/types.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Function signature for callbacks registered for receiving events on a channel. 4 | * 5 | * @template TData The type of data contained in the event message. 6 | * @callback EventCallback 7 | * @param {EventMessage} event - The event message that was published on the channel. 8 | * @returns {void} 9 | * 10 | * @example 11 | * const callback: EventCallback = (message) => { 12 | * console.log(`Received message on channel ${message.channel}: ${message.data}`); 13 | * }; 14 | */ 15 | export type EventCallback = (data:TData) => void | Promise; 16 | 17 | /** 18 | * Constant representing the wild card channel where ALL events get broadcast to. 19 | */ 20 | export const WildCardChannel = '*'; 21 | 22 | /** 23 | * Subscription object returned when a callback is subscribed to an EventHub channel. 24 | * It contains the unsubscribe function to de-register a callback from the EventHub channel 25 | * and the unique identifier for this subscription. 26 | * 27 | * @property {() => void} unsubscribe - Function to unsubscribe the callback from the channel. 28 | * @property {number} id - Unique identifier for this subscription. 29 | * 30 | * @example 31 | * const subscription = eventHub.subscribe('myChannel', myCallback); 32 | * // Later, to unsubscribe: 33 | * subscription.unsubscribe(); 34 | */ 35 | export interface Subscription { 36 | unsubscribe: () => void; 37 | id: number; 38 | } 39 | 40 | /** 41 | * Represents metrics for a channel, including the number of publishes, errors, and the last publish time. 42 | * 43 | * @property {number} publishCount - The total number of events published on the channel. 44 | * @property {number} errorCount - The total number of errors encountered while publishing events on the channel. 45 | * @property {number} lastPublishTime - The timestamp of the last event published on the channel. 46 | * 47 | * @example 48 | * const metrics: ChannelMetrics = { 49 | * publishCount: 10, 50 | * errorCount: 2, 51 | * lastPublishTime: 1621234567890 52 | * }; 53 | */ 54 | export interface ChannelMetrics { 55 | publishCount: number; 56 | errorCount: number; 57 | lastPublishTime: number; 58 | } 59 | 60 | /** 61 | * Type representing a unique identifier for a channel in the event system 62 | * 63 | * @description 64 | * ChannelId is a string that uniquely identifies a channel in the event system. 65 | * It is used to route events to the correct subscribers and maintain channel-specific state. 66 | * 67 | * @example 68 | * const channelId: ChannelId = 'user-events'; 69 | * const channel = eventHub.subscribe(channelId, callback); 70 | */ 71 | export type ChannelId = string; 72 | 73 | /** 74 | * Type representing a unique identifier for a callback subscription 75 | * 76 | * @description 77 | * CallbackId is a numeric identifier assigned to each callback when it is 78 | * subscribed to a channel. This ID is used internally to manage subscriptions 79 | * and is included in the Subscription object returned when subscribing. 80 | * 81 | * @example 82 | * const subscription = channel.subscribe(callback); 83 | * console.log(subscription.id); // Prints the CallbackId 84 | */ 85 | export type CallbackId = number; 86 | 87 | /** 88 | * Represents a list of callbacks subscribed to a channel, indexed by their subscription IDs. 89 | * 90 | * @template TData The type of data that the event messages include. 91 | */ 92 | export type CallbackList = Map>; 93 | 94 | /** 95 | * Interface defining the contract for a Channel implementation. 96 | * 97 | * @template TData The type of data that this channel handles 98 | */ 99 | export interface IChannel { 100 | /** 101 | * Subscribes to events on the channel 102 | * @param callback The function to be called when an event is published 103 | * @param options Subscription options including replay and group 104 | */ 105 | subscribe(callback: EventCallback, options?: SubscribeOptions): Subscription; 106 | 107 | /** 108 | * Publishes an event to all subscribers 109 | * @param data The event data to publish 110 | */ 111 | publish(data: TData): Promise; 112 | 113 | /** 114 | * Gets the last event published on this channel 115 | */ 116 | readonly lastEvent: TData | undefined; 117 | 118 | /** 119 | * Gets the name of the channel 120 | */ 121 | readonly name: string; 122 | 123 | /** 124 | * Gets the list of callbacks subscribed to this channel 125 | */ 126 | readonly callbacks: CallbackList; 127 | 128 | /** 129 | * Gets the metrics for this channel 130 | */ 131 | readonly metrics: ChannelMetrics; 132 | } 133 | 134 | /** 135 | * Subscribe Options for Channel Subscription 136 | * 137 | * @property {boolean} [replay=false] - If true, immediately calls the callback with the last event, if one exists. 138 | * @property {string} [group] - The group to which the subscription belongs to enable faster unsubscribing for more complex use cases. 139 | * 140 | * @example 141 | * const options: SubscribeOptions = { 142 | * replay: true, 143 | * group: 'myGroup', 144 | * }; 145 | */ 146 | export interface SubscribeOptions { 147 | replay?: boolean; 148 | group?: string; 149 | } 150 | 151 | -------------------------------------------------------------------------------- /packages/event-hub/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "strict": true, 6 | "declaration": true, 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "resolveJsonModule": true, 11 | "baseUrl": "./src", 12 | "outDir": "./dist", 13 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"], 14 | "paths": { 15 | "*": ["*"] 16 | } 17 | }, 18 | "include": ["src/**/*.ts"], 19 | "exclude": ["node_modules", "src/**/*.spec.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/event-hub/tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [".eslintrc.js", "jest.config.js", "rollup.config.js", "tools/**/*.js", "src/**/*.ts"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | --------------------------------------------------------------------------------