[← Back to Root](../../README.md) # Event Bus (`@repo/core-events`) The Global Pub/Sub & Hardware Integration Blueprint. > This module provides a strictly-typed, global event bus for the monorepo ecosystem. It decouples cross-component communication and manages real-time hardware signals (such as printers and POS peripherals), ensuring a reactive and memory-safe architecture across all applications. --- ## 🧠 System 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 custom React hooks. **This package is a pure tool.** It ships zero application-specific events. Each consuming app (`apps/web`, `apps/desktop`, etc.) registers its own events autonomously using **TypeScript Declaration Merging** — the exact same Inversion of Control (IoC) pattern utilized by our `@repo/core-api` factory and `@repo/core-storage` engine. ### Architectural Topology ### 1. Conceptual Topology: The Pub/Sub Data Flow This diagram illustrates the high-level concept of our decoupled architecture, demonstrating how application-specific types merge into the core bus. ```mermaid graph LR %% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ─── classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a classDef coreEntity fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#0f172a classDef busEntity fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff %% ─── Nodes ─── TYPES[[App-Specific Event Types]] PUB([Publisher Component]) BUS{Global Event Bus 'mitt'} SUB([Subscriber Component]) %% ─── Flow ─── TYPES -.->|Declaration Merging| BUS PUB ===>|emit 'event', payload| BUS BUS ===>|useAppEvent 'event'| SUB %% ─── Apply Styles ─── class TYPES,PUB,SUB appEntity; class BUS busEntity; ``` ### 2. System Architecture: Core Engine vs. App Autonomy This detailed diagram shows the exact boundaries between the @repo/core-events engine and the consuming application, highlighting real-world publishers (e.g., Cashier UI) and subscribers. ```mermaid graph TD %% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ─── classDef appComponent fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a classDef injection fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff classDef registry fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff classDef coreBus fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff %% ─── Subgraphs ─── subgraph Core ["@repo/core-events (Pure Tool)"] R[AppEventRegistry Empty Interface] T[AppEvents Mapped Type] E((Global Event Bus mitt)) H[Hooks: useAppEvent / usePublishEvent] end subgraph Apps ["apps/web (App Autonomy)"] D[[events.d.ts Declaration Merging]] %% Publishers A([Cashier UI]) B([Profile Settings]) C([WebSocket Client]) %% Subscribers X([Electron IPC Bridge]) Y([IndexedDB Sync]) Z([Stock Grid Row]) end %% ─── Flow & Relationships ─── D -.->|Augments| R R ---> T ---> E E ---> H %% Emitting Events A ===>|DEVICE:PRINT_RECEIPT| E B ===>|AUTH:PROFILE_UPDATED| E C ===>|WS:STOCK_UPDATE| E %% Subscribing to Events E -.->|Triggers| X E -.->|Triggers| Y E -.->|Triggers| Z %% ─── Apply Styles ─── class A,B,C,X,Y,Z appComponent; class D injection; class R,T registry; class E,H coreBus; %% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ─── style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5 style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5 ``` ### Core Value Proposition By routing communication through this centralized event bus, we achieve: * **App Autonomy**: The core defines the engine. The app defines the contract. There is zero circular dependency. * **Zero Coupling**: Publishers and subscribers do not need to import, reference, or know about each other's existence. * **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets or hardware signals) 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, proactively preventing the most common source of memory leaks in Single Page Architectures (SPAs). --- ## 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; } ```