309 lines
9.9 KiB
Markdown
309 lines
9.9 KiB
Markdown
# @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.
|
|
|
|
**This package is a pure tool.** It ships zero application-specific events. Each consuming app (`apps/web`, `apps/landing`, etc.) registers its own events autonomously using TypeScript Declaration Merging — the same Inversion of Control pattern used by `@repo/core-api`'s `createHttpClient` factory.
|
|
|
|
By routing communication through a centralized event bus, we achieve:
|
|
- **App Autonomy**: The core defines the bus. The app defines the contract. No circular knowledge.
|
|
- **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.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
graph TD
|
|
subgraph "@repo/core-events (Pure Tool)"
|
|
R["AppEventRegistry<br/>(empty interface)"]
|
|
T["AppEvents = mapped type"]
|
|
E((Event Bus<br/>mitt))
|
|
H[useAppEvent / usePublishEvent]
|
|
R --> T --> E
|
|
E --> H
|
|
end
|
|
|
|
subgraph "apps/web (App Autonomy)"
|
|
D["events.d.ts<br/>declare module augmentation"]
|
|
A[Cashier UI]
|
|
B[Profile Settings]
|
|
C[WebSocket Client]
|
|
X[Electron IPC Bridge]
|
|
Y[IndexedDB Sync]
|
|
Z[Stock Grid Row]
|
|
end
|
|
|
|
D -. "merges into" .-> R
|
|
|
|
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
|
|
style D fill:#e67700,color:#fff,stroke:#fff
|
|
```
|
|
|
|
---
|
|
|
|
## Defining Events (Module Augmentation)
|
|
|
|
> [!IMPORTANT]
|
|
> **Do NOT add application events to `packages/core-events/src/events.registry.ts`.**
|
|
> The core registry is intentionally empty. Each app owns its own event contract.
|
|
|
|
The core exports an open `AppEventRegistry` interface. Apps extend it using TypeScript's `declare module` syntax — the same pattern used for `@types/*` across the JS ecosystem.
|
|
|
|
### Step 1: Create an augmentation file in your app
|
|
|
|
> [!WARNING]
|
|
> The `import type {}` line is **mandatory**. Without it, TypeScript treats `declare module` as an ambient module declaration that **replaces** the module's types instead of merging into them. All actual exports (`useAppEvent`, `publish`, etc.) would become invisible.
|
|
|
|
```typescript
|
|
// apps/web/src/types/events.d.ts
|
|
|
|
// This import makes this file a module augmentation (merge)
|
|
// instead of an ambient declaration (replace).
|
|
import type {} from '@repo/core-events';
|
|
|
|
declare module '@repo/core-events' {
|
|
// Define your payload shapes
|
|
interface OrderPayload {
|
|
orderId: string;
|
|
total: number;
|
|
items: Array<{ sku: string; qty: number }>;
|
|
}
|
|
|
|
// Extend the registry
|
|
interface AppEventRegistry {
|
|
'STORE:ORDER_PLACED': OrderPayload;
|
|
'STORE:ORDER_CANCELLED': { orderId: string; reason: string };
|
|
'UI:SIDEBAR_TOGGLED': { collapsed: boolean };
|
|
|
|
// Explicit payloads for the examples below:
|
|
'DEVICE:PRINT_RECEIPT': { receiptId: string; items: any[]; total: number; cashierName: string; timestamp: number };
|
|
'WS:STOCK_UPDATE': { id: string; price: number };
|
|
'AUTH:PROFILE_UPDATED': { id: string; name: string; email: string; avatar: string; updatedAt: number };
|
|
'SYSTEM:ERROR': { source: string; error: Error };
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 2: Use it — autocomplete works immediately
|
|
|
|
```tsx
|
|
import { usePublishEvent, useAppEvent } from '@repo/core-events';
|
|
|
|
function CheckoutButton() {
|
|
const publish = usePublishEvent();
|
|
// ✅ 'STORE:ORDER_PLACED' autocompletes.
|
|
// ✅ Payload shape is enforced by TypeScript.
|
|
publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [] });
|
|
}
|
|
|
|
function OrderTracker() {
|
|
// ✅ payload is fully typed as OrderPayload
|
|
useAppEvent('STORE:ORDER_PLACED', (payload) => {
|
|
console.log(payload.orderId); // string
|
|
});
|
|
}
|
|
```
|
|
|
|
### Why this pattern?
|
|
|
|
| Concern | Old (Hardcoded) | New (Module Augmentation) |
|
|
|---|---|---|
|
|
| Core knows about app events? | ❌ Yes — violates IoC | ✅ No — core is a pure tool |
|
|
| Adding events requires editing core? | ❌ Yes | ✅ No — edit your app's `.d.ts` only |
|
|
| Multiple apps share the same registry? | ❌ Collision risk | ✅ Each app has its own `.d.ts` |
|
|
| Type safety / autocomplete | ✅ Works | ✅ Works identically |
|
|
|
|
---
|
|
|
|
## Usage Outside React (Vanilla TS)
|
|
|
|
For utility files, API interceptors, Web Workers, or vanilla functions where React hooks cannot be used, import the raw `eventBus` instance directly.
|
|
|
|
```ts
|
|
import { eventBus } from '@repo/core-events';
|
|
|
|
// Publishing
|
|
eventBus.publish('STORE:ORDER_CANCELLED', { orderId: '123', reason: 'Out of stock' });
|
|
|
|
// Subscribing
|
|
const handler = (payload) => {
|
|
console.log('Order cancelled:', payload.orderId);
|
|
};
|
|
|
|
eventBus.subscribe('STORE:ORDER_CANCELLED', handler);
|
|
|
|
// CRITICAL: Always unsubscribe when done to prevent memory leaks in non-React contexts!
|
|
eventBus.unsubscribe('STORE:ORDER_CANCELLED', handler);
|
|
```
|
|
|
|
---
|
|
|
|
## Usage Examples
|
|
|
|
Here are three real-world architectural patterns powered by the Event Bus. All event types below are registered in `apps/web/src/types/events.d.ts`, **not** in the core package.
|
|
|
|
### 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',
|
|
timestamp: Date.now(),
|
|
});
|
|
};
|
|
|
|
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, properly escalating errors if the storage fails.
|
|
|
|
**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',
|
|
avatar: '[https://example.com/avatar.png](https://example.com/avatar.png)',
|
|
updatedAt: Date.now(),
|
|
});
|
|
};
|
|
|
|
return <Button onClick={handleSave}>Save Profile</Button>;
|
|
}
|
|
```
|
|
|
|
**Subscriber (Storage Sync Listener)**:
|
|
```tsx
|
|
import { useAppEvent, usePublishEvent } from '@repo/core-events';
|
|
import { secureIndexedDB } from '@repo/core-storage';
|
|
|
|
export function StorageSyncListener() {
|
|
const publish = usePublishEvent();
|
|
|
|
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((error) => {
|
|
// Escalate to global error handler instead of swallowing it
|
|
publish('SYSTEM:ERROR', { source: 'StorageSyncListener', error });
|
|
});
|
|
});
|
|
|
|
return null;
|
|
}
|
|
``` |