├── .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 | [](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