refactor: decouple core-events registry by implementing consumer-side TypeScript module augmentation and reorganizing showcase demos

This commit is contained in:
Firman Ramdhani
2026-05-28 11:51:43 +07:00
parent 1c6d76bf4f
commit df229c9984
13 changed files with 264 additions and 140 deletions
+62 -29
View File
@@ -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);
+38 -70
View File
@@ -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<string, unknown>`
* 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];
};
+1 -7
View File
@@ -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';