feat: introduce core-events package with event bus, hooks, and a web showcase demo
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
# @repo/core-events
|
||||
|
||||
[← Back to Root](../../README.md)
|
||||
|
||||
## Overview
|
||||
|
||||
`@repo/core-events` is the **decoupled Nervous System** of the ERP. It provides a highly performant, strictly typed Event Bus powered by `mitt` and React hooks.
|
||||
|
||||
By routing communication through a centralized event bus, we achieve:
|
||||
- **Zero Coupling**: Publishers and subscribers don't need to import or know about each other.
|
||||
- **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets) and update their own local state *without* triggering massive React tree re-renders.
|
||||
- **Memory Safety**: The provided `useAppEvent` hook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures.
|
||||
- **Strict Contracts**: The `AppEvents` registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Publishers
|
||||
A[Cashier UI]
|
||||
B[Profile Settings]
|
||||
C[WebSocket Client]
|
||||
end
|
||||
|
||||
subgraph Core
|
||||
E((Event Bus<br/>mitt))
|
||||
R[[AppEvents<br/>Registry]] -.-> E
|
||||
end
|
||||
|
||||
subgraph Subscribers
|
||||
X[Electron IPC Bridge]
|
||||
Y[IndexedDB Sync]
|
||||
Z[Stock Grid Row]
|
||||
end
|
||||
|
||||
A -- "DEVICE:PRINT_RECEIPT" --> E
|
||||
B -- "AUTH:PROFILE_UPDATED" --> E
|
||||
C -- "WS:STOCK_UPDATE" --> E
|
||||
|
||||
E -.-> X
|
||||
E -.-> Y
|
||||
E -.-> Z
|
||||
|
||||
style E fill:#4263eb,color:#fff,stroke:#fff
|
||||
style R fill:#2b8a3e,color:#fff,stroke:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Defining Events
|
||||
|
||||
Every event in the system MUST be registered in `src/events.registry.ts`. This provides a single source of truth and full autocomplete across the codebase.
|
||||
|
||||
To add a new event, simply extend the `AppEvents` type:
|
||||
|
||||
```typescript
|
||||
// packages/core-events/src/events.registry.ts
|
||||
|
||||
export interface CheckoutPayload {
|
||||
orderId: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type AppEvents = {
|
||||
// Existing events...
|
||||
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
|
||||
|
||||
// Your new event:
|
||||
'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Here are three real-world architectural patterns powered by the Event Bus.
|
||||
|
||||
### Example 1: Hardware Abstraction (Cross-Platform)
|
||||
|
||||
**Problem**: The web app needs to print receipts. If running in a browser, it should use `window.print()`. If running in the Electron wrapper, it must use the secure IPC bridge (`window.electronAPI.print()`). We don't want the UI components cluttered with platform-detection logic.
|
||||
|
||||
**Solution**: The UI publishes a blind event. A headless listener handles the platform routing.
|
||||
|
||||
**Publisher (Cashier UI)**:
|
||||
```tsx
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
|
||||
export function CashierUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const handlePrint = () => {
|
||||
// Fire and forget. Zero knowledge of how printing actually happens.
|
||||
publish('DEVICE:PRINT_RECEIPT', {
|
||||
receiptId: 'RCP-123',
|
||||
items: [...],
|
||||
total: 45.00,
|
||||
cashierName: 'Firman'
|
||||
});
|
||||
};
|
||||
|
||||
return <Button onClick={handlePrint}>Print Receipt</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Subscriber (Headless Listener)**:
|
||||
```tsx
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
|
||||
export function PrinterListener() {
|
||||
useAppEvent('DEVICE:PRINT_RECEIPT', (payload) => {
|
||||
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||
|
||||
if (isElectron) {
|
||||
// Route via secure Electron IPC bridge
|
||||
window.electronAPI.print({ silent: true });
|
||||
} else {
|
||||
// Fallback to standard browser print dialog
|
||||
window.print();
|
||||
}
|
||||
});
|
||||
|
||||
return null; // Renders nothing
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Extreme Performance (High-Frequency Data)
|
||||
|
||||
**Problem**: A massive data grid (1,000+ rows) receives 50 WebSocket updates per second. If the parent grid holds the state and passes it down via props, React will attempt to re-render all 1,000 rows 50 times a second, crushing the browser.
|
||||
|
||||
**Solution**: The parent grid renders empty rows. Each row subscribes to the event bus and filters updates so it only re-renders when its specific data changes.
|
||||
|
||||
**Parent Grid (Never re-renders)**:
|
||||
```tsx
|
||||
export function LiveStockGrid() {
|
||||
// Generates 1000 IDs once. No stock data is stored here!
|
||||
const stockIds = generateStockIds(1000);
|
||||
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{stockIds.map((id) => (
|
||||
<StockRow key={id} stockId={id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Child Row (Targeted Updates)**:
|
||||
```tsx
|
||||
import { memo, useState } from 'react';
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
|
||||
export const StockRow = memo(function StockRow({ stockId }) {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useAppEvent('WS:STOCK_UPDATE', (payload) => {
|
||||
// CRITICAL: Filter out events for other rows.
|
||||
// 999 out of 1000 rows will exit here instantly without causing a re-render.
|
||||
if (payload.id !== stockId) return;
|
||||
|
||||
// Only the targeted row updates its local state
|
||||
setData(payload);
|
||||
});
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{stockId}</td>
|
||||
<td>{data?.price}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Background Sync (Auth to IndexedDB)
|
||||
|
||||
**Problem**: When a user updates their profile, we need to persist it to the secure local IndexedDB. We don't want to tightly couple our UI forms to the `@repo/core-storage` package.
|
||||
|
||||
**Solution**: The UI form announces the profile update. A dedicated storage listener persists it in the background.
|
||||
|
||||
**Publisher (Profile UI)**:
|
||||
```tsx
|
||||
import { usePublishEvent } from '@repo/core-events';
|
||||
|
||||
export function ProfileSettingsUI() {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const handleSave = () => {
|
||||
publish('AUTH:PROFILE_UPDATED', {
|
||||
id: 'user-1',
|
||||
name: 'Firman',
|
||||
email: 'firman@eigen.co.id',
|
||||
});
|
||||
};
|
||||
|
||||
return <Button onClick={handleSave}>Save Profile</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Subscriber (Storage Sync Listener)**:
|
||||
```tsx
|
||||
import { useAppEvent } from '@repo/core-events';
|
||||
import { secureIndexedDB } from '@repo/core-storage';
|
||||
|
||||
export function StorageSyncListener() {
|
||||
useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
|
||||
// Automatically encrypted at rest because 'user_profile'
|
||||
// is defined in ENCRYPTED_KEYS in @repo/core-storage
|
||||
secureIndexedDB.setItem('user_profile', payload).catch(console.error);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user