Skip to content

Event Bus (@repo/core-events)

Architectural Foundation: mitt

Description: Strictly-typed global event bus powered by mitt, enabling decoupled pub/sub communication across React components with automatic memory-safe lifecycle cleanup.

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.

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.

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)

> **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?

ConcernOld (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.0,
      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;
}