Files
trackgo-fe/packages/core-events/README.md
T

11 KiB

@repo/core-events

← Back to Root

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

graph TD
    subgraph Core ["@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 ["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

    %% 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.

// 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

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.

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):

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):

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):

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):

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):

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):

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;
}