# @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 Core ["@repo/core-events (Pure Tool)"] R["AppEventRegistry
(empty interface)"] T["AppEvents = mapped type"] E((Event Bus
mitt)) H[useAppEvent / usePublishEvent] R --> T --> E E --> H end subgraph Apps ["apps/web (App Autonomy)"] D["events.d.ts
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 %% Styling Subgraphs (Backgrounds) style Core fill:#f8f9fa,stroke:#ced4da,stroke-width:2px,color:#495057 style Apps fill:#e7f5ff,stroke:#74c0fc,stroke-width:2px,color:#1864ab %% Styling Core Engine (Purple) & Contracts (Green) style R fill:#20c997,stroke:#089981,color:#fff style T fill:#20c997,stroke:#089981,color:#fff style E fill:#845ef7,stroke:#5f3dc4,color:#fff style H fill:#845ef7,stroke:#5f3dc4,color:#fff %% Styling App Injection (Orange) & Components (Blue) style D fill:#fd7e14,stroke:#d9480f,color:#fff style A fill:#339af0,stroke:#1864ab,color:#fff style B fill:#339af0,stroke:#1864ab,color:#fff style C fill:#339af0,stroke:#1864ab,color:#fff style X fill:#339af0,stroke:#1864ab,color:#fff style Y fill:#339af0,stroke:#1864ab,color:#fff style Z fill:#339af0,stroke:#1864ab,color:#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 ; } ``` **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 ( {stockIds.map((id) => ( ))}
); } ``` **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 ( {stockId} {data?.price} ); }); ``` --- ### 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 ; } ``` **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; } ```