From df229c99841a0551ca253427a00fd4d3675443ae Mon Sep 17 00:00:00 2001
From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com>
Date: Thu, 28 May 2026 11:51:43 +0700
Subject: [PATCH] refactor: decouple core-events registry by implementing
consumer-side TypeScript module augmentation and reorganizing showcase demos
---
...SettingsUI.tsx => profile-settings.ui.tsx} | 0
...Listener.tsx => storage-sync.listener.tsx} | 2 +-
.../src/apps/showcase/events-demo/index.tsx | 10 +-
.../printer/{CashierUI.tsx => cashier.ui.tsx} | 0
...interListener.tsx => printer.listener.tsx} | 0
...veStockGrid.tsx => live-stock-grid.ui.tsx} | 4 +-
...WebSocket.ts => mock-websocket.service.ts} | 0
.../{StockRow.tsx => stock-row.ui.tsx} | 0
apps/web/src/types/events.d.ts | 76 ++++++++++++
packages/core-events/README.md | 105 ++++++++++++-----
packages/core-events/src/event-bus.test.ts | 91 ++++++++++-----
packages/core-events/src/events.registry.ts | 108 ++++++------------
packages/core-events/src/index.ts | 8 +-
13 files changed, 264 insertions(+), 140 deletions(-)
rename apps/web/src/apps/showcase/events-demo/auth-sync/{ProfileSettingsUI.tsx => profile-settings.ui.tsx} (100%)
rename apps/web/src/apps/showcase/events-demo/auth-sync/{StorageSyncListener.tsx => storage-sync.listener.tsx} (97%)
rename apps/web/src/apps/showcase/events-demo/printer/{CashierUI.tsx => cashier.ui.tsx} (100%)
rename apps/web/src/apps/showcase/events-demo/printer/{PrinterListener.tsx => printer.listener.tsx} (100%)
rename apps/web/src/apps/showcase/events-demo/stock-grid/{LiveStockGrid.tsx => live-stock-grid.ui.tsx} (98%)
rename apps/web/src/apps/showcase/events-demo/stock-grid/{MockWebSocket.ts => mock-websocket.service.ts} (100%)
rename apps/web/src/apps/showcase/events-demo/stock-grid/{StockRow.tsx => stock-row.ui.tsx} (100%)
create mode 100644 apps/web/src/types/events.d.ts
diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx
similarity index 100%
rename from apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx
rename to apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx
diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx
similarity index 97%
rename from apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx
rename to apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx
index 8d89489..e1fa14f 100644
--- a/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx
+++ b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx
@@ -36,7 +36,7 @@ interface StorageSyncListenerProps {
* ```
*
* 3. For bidirectional sync (storage → event), consider adding a
- * `STORAGE:PROFILE_LOADED` event to `AppEvents` that fires when
+ * `STORAGE:PROFILE_LOADED` event to `AppEventRegistry` that fires when
* the app reads the profile from IndexedDB on boot.
*
* 4. Error handling: In production, wrap the `setItem` call in a
diff --git a/apps/web/src/apps/showcase/events-demo/index.tsx b/apps/web/src/apps/showcase/events-demo/index.tsx
index 0581990..664b057 100644
--- a/apps/web/src/apps/showcase/events-demo/index.tsx
+++ b/apps/web/src/apps/showcase/events-demo/index.tsx
@@ -9,11 +9,11 @@ import {
} from '@repo/ui/components';
// ── Showcase Components ──────────────────────────────────────────
-import { CashierUI } from './printer/CashierUI';
-import { PrinterListener } from './printer/PrinterListener';
-import { LiveStockGrid } from './stock-grid/LiveStockGrid';
-import { ProfileSettingsUI } from './auth-sync/ProfileSettingsUI';
-import { StorageSyncListener } from './auth-sync/StorageSyncListener';
+import { CashierUI } from './printer/cashier.ui';
+import { PrinterListener } from './printer/printer.listener';
+import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
+import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
+import { StorageSyncListener } from './auth-sync/storage-sync.listener';
// ─── Events Demo Page ───────────────────────────────────────────
diff --git a/apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx b/apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx
similarity index 100%
rename from apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx
rename to apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx
diff --git a/apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx b/apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx
similarity index 100%
rename from apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx
rename to apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx
diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/live-stock-grid.ui.tsx
similarity index 98%
rename from apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx
rename to apps/web/src/apps/showcase/events-demo/stock-grid/live-stock-grid.ui.tsx
index e46395f..46738e5 100644
--- a/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx
+++ b/apps/web/src/apps/showcase/events-demo/stock-grid/live-stock-grid.ui.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
-import { StockRow } from './StockRow';
-import { generateStockIds, startMockWebSocket } from './MockWebSocket';
+import { StockRow } from './stock-row.ui';
+import { generateStockIds, startMockWebSocket } from './mock-websocket.service';
// ─── Constants ──────────────────────────────────────────────────
diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts b/apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts
similarity index 100%
rename from apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts
rename to apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts
diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx
similarity index 100%
rename from apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx
rename to apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx
diff --git a/apps/web/src/types/events.d.ts b/apps/web/src/types/events.d.ts
new file mode 100644
index 0000000..e95b272
--- /dev/null
+++ b/apps/web/src/types/events.d.ts
@@ -0,0 +1,76 @@
+/**
+ * App-level event registry for `apps/web`.
+ *
+ * This file uses TypeScript Declaration Merging (Module Augmentation)
+ * to extend the open `AppEventRegistry` interface exported by
+ * `@repo/core-events`. This is the IoC pattern in action:
+ *
+ * - `@repo/core-events` provides the bus, hooks, and helpers (the tool).
+ * - `apps/web` defines which events exist and their payload shapes (the contract).
+ *
+ * The core package has zero knowledge of these events. If `apps/web`
+ * is removed from the monorepo, the core package remains unchanged.
+ *
+ * **Adding new events**: Simply add new entries to `AppEventRegistry`
+ * below. TypeScript will automatically provide autocomplete and
+ * type safety across every `publish()` / `useAppEvent()` call in
+ * the web app.
+ *
+ * @see packages/core-events/src/events.registry.ts
+ */
+
+// This import turns this file from an ambient declaration into a
+// module augmentation. Without it, `declare module` would REPLACE
+// the module signature instead of merging into it.
+import type {} from '@repo/core-events';
+
+declare module '@repo/core-events' {
+
+ // ─── Payload Types ──────────────────────────────────────────
+
+ interface ReceiptItem {
+ name: string;
+ qty: number;
+ price: number;
+ }
+
+ interface PrintReceiptPayload {
+ receiptId: string;
+ items: ReceiptItem[];
+ total: number;
+ cashierName: string;
+ timestamp: number;
+ }
+
+ interface StockUpdatePayload {
+ id: string;
+ price: number;
+ change: number;
+ volume: number;
+ }
+
+ interface ProfileUpdatedPayload {
+ id: string;
+ name: string;
+ email: string;
+ avatar: string;
+ updatedAt: number;
+ }
+
+ // ─── Event Registry ─────────────────────────────────────────
+
+ interface AppEventRegistry {
+ // ── Device / Hardware ─────────────────────────────────────
+ 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
+
+ // ── WebSocket / Real-Time ────────────────────────────────
+ 'WS:STOCK_UPDATE': StockUpdatePayload;
+
+ // ── Auth / User ──────────────────────────────────────────
+ 'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload;
+
+ // ── App Lifecycle ────────────────────────────────────────
+ 'APP:INITIALIZED': undefined;
+ 'APP:ERROR': { message: string; code?: string };
+ }
+}
diff --git a/packages/core-events/README.md b/packages/core-events/README.md
index 3b3c46a..0c9dcc5 100644
--- a/packages/core-events/README.md
+++ b/packages/core-events/README.md
@@ -6,11 +6,13 @@
`@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.
-- **Strict Contracts**: The `AppEvents` registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract.
---
@@ -18,23 +20,27 @@ By routing communication through a centralized event bus, we achieve:
```mermaid
graph TD
- subgraph Publishers
+ subgraph "@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/web (App Autonomy)"
+ D["events.d.ts
declare module augmentation"]
A[Cashier UI]
B[Profile Settings]
C[WebSocket Client]
- end
-
- subgraph Core
- E((Event Bus
mitt))
- R[[AppEvents
Registry]] -.-> E
- end
-
- subgraph Subscribers
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
@@ -45,38 +51,82 @@ graph TD
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
+## Defining Events (Module Augmentation)
-Every event in the system MUST be registered in `src/events.registry.ts`. This provides a single source of truth and full autocomplete across the codebase.
+> [!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.
-To add a new event, simply extend the `AppEvents` type:
+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
-// packages/core-events/src/events.registry.ts
+// apps/web/src/types/events.d.ts
-export interface CheckoutPayload {
- orderId: string;
- total: number;
+// 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 };
+ }
+}
+```
+
+### 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: [...] });
}
-export type AppEvents = {
- // Existing events...
- 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
-
- // Your new event:
- 'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
-};
+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 Examples
-Here are three real-world architectural patterns powered by the Event Bus.
+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)
@@ -97,7 +147,8 @@ export function CashierUI() {
receiptId: 'RCP-123',
items: [...],
total: 45.00,
- cashierName: 'Firman'
+ cashierName: 'Firman',
+ timestamp: Date.now(),
});
};
@@ -198,6 +249,8 @@ export function ProfileSettingsUI() {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
+ avatar: 'https://example.com/avatar.png',
+ updatedAt: Date.now(),
});
};
diff --git a/packages/core-events/src/event-bus.test.ts b/packages/core-events/src/event-bus.test.ts
index 0e47d92..ca807b6 100644
--- a/packages/core-events/src/event-bus.test.ts
+++ b/packages/core-events/src/event-bus.test.ts
@@ -2,7 +2,40 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { eventBus, publish, subscribe } from './event-bus';
import { useAppEvent, usePublishEvent } from './hooks';
-import type { StockUpdatePayload, ProfileUpdatedPayload } from './events.registry';
+
+// ─── Test-Local Event Augmentation ──────────────────────────────
+//
+// In a real app, these would live in a `.d.ts` file inside the app's
+// `src/types/` folder using `declare module '@repo/core-events'`.
+//
+// For the core package's own test suite, we augment the registry
+// directly here so the tests have concrete event types to work with
+// without polluting the core's shipped types.
+//
+declare module './events.registry' {
+ interface AppEventRegistry {
+ 'TEST:STOCK_UPDATE': StockUpdatePayload;
+ 'TEST:PROFILE_UPDATED': ProfileUpdatedPayload;
+ 'TEST:INITIALIZED': undefined;
+ }
+}
+
+// ─── Test Payload Types ─────────────────────────────────────────
+
+interface StockUpdatePayload {
+ id: string;
+ price: number;
+ change: number;
+ volume: number;
+}
+
+interface ProfileUpdatedPayload {
+ id: string;
+ name: string;
+ email: string;
+ avatar: string;
+ updatedAt: number;
+}
// ─── Helpers ────────────────────────────────────────────────────
@@ -42,8 +75,8 @@ describe('Event Bus (Core)', () => {
it('publishes and subscribes to a typed event', () => {
const handler = vi.fn();
- subscribe('WS:STOCK_UPDATE', handler);
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ subscribe('TEST:STOCK_UPDATE', handler);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
@@ -53,9 +86,9 @@ describe('Event Bus (Core)', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
- subscribe('AUTH:PROFILE_UPDATED', handler1);
- subscribe('AUTH:PROFILE_UPDATED', handler2);
- publish('AUTH:PROFILE_UPDATED', mockProfile);
+ subscribe('TEST:PROFILE_UPDATED', handler1);
+ subscribe('TEST:PROFILE_UPDATED', handler2);
+ publish('TEST:PROFILE_UPDATED', mockProfile);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
@@ -65,10 +98,10 @@ describe('Event Bus (Core)', () => {
const stockHandler = vi.fn();
const profileHandler = vi.fn();
- subscribe('WS:STOCK_UPDATE', stockHandler);
- subscribe('AUTH:PROFILE_UPDATED', profileHandler);
+ subscribe('TEST:STOCK_UPDATE', stockHandler);
+ subscribe('TEST:PROFILE_UPDATED', profileHandler);
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
expect(stockHandler).toHaveBeenCalledTimes(1);
expect(profileHandler).not.toHaveBeenCalled();
@@ -77,15 +110,15 @@ describe('Event Bus (Core)', () => {
it('unsubscribes correctly via returned function', () => {
const handler = vi.fn();
- const unsub = subscribe('WS:STOCK_UPDATE', handler);
+ const unsub = subscribe('TEST:STOCK_UPDATE', handler);
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
// Unsubscribe
unsub();
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
// Should still be 1, not 2
expect(handler).toHaveBeenCalledTimes(1);
});
@@ -93,8 +126,8 @@ describe('Event Bus (Core)', () => {
it('handles events with undefined payloads', () => {
const handler = vi.fn();
- subscribe('APP:INITIALIZED', handler);
- publish('APP:INITIALIZED', undefined);
+ subscribe('TEST:INITIALIZED', handler);
+ publish('TEST:INITIALIZED', undefined);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(undefined);
@@ -102,10 +135,10 @@ describe('Event Bus (Core)', () => {
it('handles rapid-fire events without loss', () => {
const handler = vi.fn();
- subscribe('WS:STOCK_UPDATE', handler);
+ subscribe('TEST:STOCK_UPDATE', handler);
for (let i = 0; i < 1000; i++) {
- publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
+ publish('TEST:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
}
expect(handler).toHaveBeenCalledTimes(1000);
@@ -126,10 +159,10 @@ describe('useAppEvent (React Hook)', () => {
it('subscribes on mount and receives events', () => {
const handler = vi.fn();
- renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
+ renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
act(() => {
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
@@ -139,11 +172,11 @@ describe('useAppEvent (React Hook)', () => {
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
const handler = vi.fn();
- const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
+ const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
// Event should be received while mounted
act(() => {
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
@@ -152,7 +185,7 @@ describe('useAppEvent (React Hook)', () => {
// Event should NOT be received after unmount
act(() => {
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
});
// Still 1, proving the handler was properly cleaned up
@@ -164,20 +197,20 @@ describe('useAppEvent (React Hook)', () => {
// Mount and unmount 100 times
for (let i = 0; i < 100; i++) {
- const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
+ const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
unmount();
}
// After 100 cycles, emit one event
act(() => {
- publish('WS:STOCK_UPDATE', mockStockUpdate);
+ publish('TEST:STOCK_UPDATE', mockStockUpdate);
});
// Should be 0 — all handlers should have been cleaned up
expect(handler).toHaveBeenCalledTimes(0);
// Verify the handler map is empty for this event type
- const handlers = eventBus.all.get('WS:STOCK_UPDATE');
+ const handlers = eventBus.all.get('TEST:STOCK_UPDATE');
expect(!handlers || handlers.length === 0).toBe(true);
});
@@ -186,7 +219,7 @@ describe('useAppEvent (React Hook)', () => {
const { rerender } = renderHook(
({ value }: { value: string }) =>
- useAppEvent('AUTH:PROFILE_UPDATED', () => {
+ useAppEvent('TEST:PROFILE_UPDATED', () => {
capturedValue = value;
}),
{ initialProps: { value: 'initial' } },
@@ -196,7 +229,7 @@ describe('useAppEvent (React Hook)', () => {
rerender({ value: 'updated' });
act(() => {
- publish('AUTH:PROFILE_UPDATED', mockProfile);
+ publish('TEST:PROFILE_UPDATED', mockProfile);
});
// Should capture the LATEST value, not the stale 'initial'
@@ -210,7 +243,7 @@ describe('useAppEvent (React Hook)', () => {
const { rerender } = renderHook(
({ handler }: { handler: () => void }) =>
- useAppEvent('APP:INITIALIZED', handler),
+ useAppEvent('TEST:INITIALIZED', handler),
{ initialProps: { handler: vi.fn() } },
);
@@ -240,12 +273,12 @@ describe('usePublishEvent (React Hook)', () => {
it('returns a working publish function', () => {
const handler = vi.fn();
- subscribe('AUTH:PROFILE_UPDATED', handler);
+ subscribe('TEST:PROFILE_UPDATED', handler);
const { result } = renderHook(() => usePublishEvent());
act(() => {
- result.current('AUTH:PROFILE_UPDATED', mockProfile);
+ result.current('TEST:PROFILE_UPDATED', mockProfile);
});
expect(handler).toHaveBeenCalledTimes(1);
diff --git a/packages/core-events/src/events.registry.ts b/packages/core-events/src/events.registry.ts
index 229d348..a27bc78 100644
--- a/packages/core-events/src/events.registry.ts
+++ b/packages/core-events/src/events.registry.ts
@@ -1,82 +1,50 @@
-// ─── Event Payload Types ────────────────────────────────────────
+// ─── Application Event Registry (IoC Pattern) ──────────────────
/**
- * Receipt line item for the DEVICE:PRINT_RECEIPT event.
- */
-export interface ReceiptItem {
- name: string;
- qty: number;
- price: number;
-}
-
-/**
- * Payload for the DEVICE:PRINT_RECEIPT event.
- */
-export interface PrintReceiptPayload {
- receiptId: string;
- items: ReceiptItem[];
- total: number;
- cashierName: string;
- timestamp: number;
-}
-
-/**
- * Payload for the WS:STOCK_UPDATE event.
- */
-export interface StockUpdatePayload {
- id: string;
- price: number;
- change: number;
- volume: number;
-}
-
-/**
- * Payload for the AUTH:PROFILE_UPDATED event.
- */
-export interface ProfileUpdatedPayload {
- id: string;
- name: string;
- email: string;
- avatar: string;
- updatedAt: number;
-}
-
-// ─── Application Event Registry ────────────────────────────────
-
-/**
- * Central event registry for the entire application.
+ * Open interface for application event registration.
*
- * Every event in the system MUST be declared here with its payload
- * type. This provides:
+ * **This interface is intentionally empty at the core level.**
*
- * 1. **Compile-time safety** — typos in event names are caught by TS.
- * 2. **Payload validation** — publishers and subscribers agree on shape.
- * 3. **Discoverability** — `Ctrl+Click` any event to find its contract.
+ * Each consuming app (`apps/web`, `apps/landing`, etc.) is responsible
+ * for registering its own events using TypeScript Declaration Merging
+ * (Module Augmentation). This enforces Inversion of Control:
*
- * **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
+ * - The core package provides the **tool** (bus, hooks, helpers).
+ * - The app provides the **contract** (event names and payloads).
*
- * **Extensibility**: To add events from feature modules, extend this
- * type using intersection:
+ * ## How to register events
+ *
+ * Create a `.d.ts` file anywhere in your app's `src/` folder:
*
* ```ts
- * // In your feature module types:
- * type InventoryEvents = {
- * 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number };
- * };
- * // Then merge into AppEvents in this file.
+ * // apps/web/src/types/events.d.ts
+ * declare module '@repo/core-events' {
+ * interface AppEventRegistry {
+ * 'DOMAIN:EVENT_NAME': { payload: string };
+ * }
+ * }
* ```
+ *
+ * TypeScript will automatically merge all augmentations into a single
+ * `AppEventRegistry` interface, giving you full autocomplete and
+ * compile-time type safety across the entire app — without the core
+ * package knowing anything about your events.
+ *
+ * **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface AppEventRegistry {}
+
+/**
+ * Resolved event map consumed by `mitt` and all public APIs.
+ *
+ * This type alias bridges the open `AppEventRegistry` interface
+ * (which supports declaration merging) to the `Record`
+ * constraint that `mitt` requires.
+ *
+ * You should never reference this type directly in consumer code.
+ * Use `AppEventRegistry` for augmentation and let the core handle the rest.
*/
export type AppEvents = {
- // ── Device / Hardware ───────────────────────────────────────────
- 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
-
- // ── WebSocket / Real-Time ───────────────────────────────────────
- 'WS:STOCK_UPDATE': StockUpdatePayload;
-
- // ── Auth / User ─────────────────────────────────────────────────
- 'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload;
-
- // ── App Lifecycle ───────────────────────────────────────────────
- 'APP:INITIALIZED': undefined;
- 'APP:ERROR': { message: string; code?: string };
+ [K in keyof AppEventRegistry]: AppEventRegistry[K];
};
diff --git a/packages/core-events/src/index.ts b/packages/core-events/src/index.ts
index 605928a..985dd7b 100644
--- a/packages/core-events/src/index.ts
+++ b/packages/core-events/src/index.ts
@@ -1,11 +1,5 @@
// ─── Event Registry ─────────────────────────────────────────────
-export type {
- AppEvents,
- PrintReceiptPayload,
- StockUpdatePayload,
- ProfileUpdatedPayload,
- ReceiptItem,
-} from './events.registry';
+export type { AppEventRegistry, AppEvents } from './events.registry';
// ─── Event Bus (Core) ───────────────────────────────────────────
export { eventBus, publish, subscribe } from './event-bus';