feat: introduce core-events package with event bus, hooks, and a web showcase demo

This commit is contained in:
Firman Ramdhani
2026-05-28 11:20:35 +07:00
parent 3621f837f6
commit 6fde65f04e
21 changed files with 2021 additions and 7 deletions
+266
View File
@@ -0,0 +1,266 @@
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';
// ─── Helpers ────────────────────────────────────────────────────
/** Clear all mitt handlers between tests to avoid cross-contamination. */
function clearAllHandlers() {
eventBus.all.clear();
}
// ─── Test Data ──────────────────────────────────────────────────
const mockStockUpdate: StockUpdatePayload = {
id: 'AAPL',
price: 185.42,
change: 1.23,
volume: 50000,
};
const mockProfile: ProfileUpdatedPayload = {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
avatar: 'https://example.com/avatar.png',
updatedAt: Date.now(),
};
// ─── Core Event Bus Tests ───────────────────────────────────────
describe('Event Bus (Core)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('publishes and subscribes to a typed event', () => {
const handler = vi.fn();
subscribe('WS:STOCK_UPDATE', handler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
});
it('delivers events to multiple subscribers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
subscribe('AUTH:PROFILE_UPDATED', handler1);
subscribe('AUTH:PROFILE_UPDATED', handler2);
publish('AUTH:PROFILE_UPDATED', mockProfile);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it('does not deliver events to unrelated subscribers', () => {
const stockHandler = vi.fn();
const profileHandler = vi.fn();
subscribe('WS:STOCK_UPDATE', stockHandler);
subscribe('AUTH:PROFILE_UPDATED', profileHandler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(stockHandler).toHaveBeenCalledTimes(1);
expect(profileHandler).not.toHaveBeenCalled();
});
it('unsubscribes correctly via returned function', () => {
const handler = vi.fn();
const unsub = subscribe('WS:STOCK_UPDATE', handler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
// Unsubscribe
unsub();
publish('WS:STOCK_UPDATE', mockStockUpdate);
// Should still be 1, not 2
expect(handler).toHaveBeenCalledTimes(1);
});
it('handles events with undefined payloads', () => {
const handler = vi.fn();
subscribe('APP:INITIALIZED', handler);
publish('APP:INITIALIZED', undefined);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(undefined);
});
it('handles rapid-fire events without loss', () => {
const handler = vi.fn();
subscribe('WS:STOCK_UPDATE', handler);
for (let i = 0; i < 1000; i++) {
publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
}
expect(handler).toHaveBeenCalledTimes(1000);
});
});
// ─── React Hook Tests ───────────────────────────────────────────
describe('useAppEvent (React Hook)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('subscribes on mount and receives events', () => {
const handler = vi.fn();
renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
});
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
const handler = vi.fn();
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
// Event should be received while mounted
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
// Unmount the component
unmount();
// Event should NOT be received after unmount
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
// Still 1, proving the handler was properly cleaned up
expect(handler).toHaveBeenCalledTimes(1);
});
it('does not leak handlers across mount/unmount cycles', () => {
const handler = vi.fn();
// Mount and unmount 100 times
for (let i = 0; i < 100; i++) {
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
unmount();
}
// After 100 cycles, emit one event
act(() => {
publish('WS: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');
expect(!handlers || handlers.length === 0).toBe(true);
});
it('always calls the latest handler (no stale closures)', () => {
let capturedValue = '';
const { rerender } = renderHook(
({ value }: { value: string }) =>
useAppEvent('AUTH:PROFILE_UPDATED', () => {
capturedValue = value;
}),
{ initialProps: { value: 'initial' } },
);
// Update the closure value
rerender({ value: 'updated' });
act(() => {
publish('AUTH:PROFILE_UPDATED', mockProfile);
});
// Should capture the LATEST value, not the stale 'initial'
expect(capturedValue).toBe('updated');
});
it('does not re-subscribe when handler reference changes', () => {
// We spy on eventBus.on to count subscription calls
const onSpy = vi.spyOn(eventBus, 'on');
const offSpy = vi.spyOn(eventBus, 'off');
const { rerender } = renderHook(
({ handler }: { handler: () => void }) =>
useAppEvent('APP:INITIALIZED', handler),
{ initialProps: { handler: vi.fn() } },
);
const initialOnCount = onSpy.mock.calls.length;
const initialOffCount = offSpy.mock.calls.length;
// Re-render with a NEW handler function reference
rerender({ handler: vi.fn() });
// on/off should NOT have been called again (ref-based pattern)
expect(onSpy.mock.calls.length).toBe(initialOnCount);
expect(offSpy.mock.calls.length).toBe(initialOffCount);
onSpy.mockRestore();
offSpy.mockRestore();
});
});
describe('usePublishEvent (React Hook)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('returns a working publish function', () => {
const handler = vi.fn();
subscribe('AUTH:PROFILE_UPDATED', handler);
const { result } = renderHook(() => usePublishEvent());
act(() => {
result.current('AUTH:PROFILE_UPDATED', mockProfile);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockProfile);
});
it('returns a referentially stable function across re-renders', () => {
const { result, rerender } = renderHook(() => usePublishEvent());
const firstRef = result.current;
rerender();
rerender();
rerender();
expect(result.current).toBe(firstRef);
});
});
+67
View File
@@ -0,0 +1,67 @@
import mitt from 'mitt';
import type { AppEvents } from './events.registry';
// ─── Singleton Event Bus ────────────────────────────────────────
/**
* Application-wide event bus — a strictly typed `mitt` instance.
*
* Prefer the `publish` / `subscribe` helper functions or the React
* hooks (`useAppEvent`, `usePublishEvent`) over using this directly.
* Direct access is provided for edge cases like middleware or testing.
*
* @example
* ```ts
* import { eventBus } from '@repo/core-events';
* eventBus.on('APP:ERROR', (e) => console.error(e.message));
* ```
*/
export const eventBus = mitt<AppEvents>();
// ─── Type-Safe Helpers ──────────────────────────────────────────
/**
* Emit (publish) a typed event to all subscribers.
*
* @param type - The event name from `AppEvents`.
* @param event - The payload matching that event's type.
*
* @example
* ```ts
* publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... });
* ```
*/
export function publish<K extends keyof AppEvents>(
type: K,
event: AppEvents[K],
): void {
eventBus.emit(type, event);
}
/**
* Subscribe to a typed event.
*
* Returns an `unsubscribe` function — call it to remove the handler.
* For React components, prefer `useAppEvent` which handles cleanup
* automatically on unmount.
*
* @param type - The event name from `AppEvents`.
* @param handler - Callback receiving the typed payload.
* @returns A function that removes this subscription.
*
* @example
* ```ts
* const unsub = subscribe('WS:STOCK_UPDATE', (data) => {
* console.log(data.price); // fully typed
* });
* // Later:
* unsub();
* ```
*/
export function subscribe<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): () => void {
eventBus.on(type, handler);
return () => eventBus.off(type, handler);
}
@@ -0,0 +1,82 @@
// ─── Event Payload Types ────────────────────────────────────────
/**
* 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.
*
* Every event in the system MUST be declared here with its payload
* type. This provides:
*
* 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.
*
* **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
*
* **Extensibility**: To add events from feature modules, extend this
* type using intersection:
*
* ```ts
* // In your feature module types:
* type InventoryEvents = {
* 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number };
* };
* // Then merge into AppEvents in this file.
* ```
*/
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 };
};
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useRef, useCallback } from 'react';
import type { AppEvents } from './events.registry';
import { eventBus, publish as busPublish } from './event-bus';
// ─── useAppEvent ────────────────────────────────────────────────
/**
* Subscribe to an application event with automatic cleanup on unmount.
*
* The handler is stored in a ref so that:
* 1. The subscription is stable — re-renders don't cause unsubscribe/resubscribe churn.
* 2. The handler always sees the latest closure values (no stale closures).
* 3. The parent component's render cycle is never triggered by the subscription itself.
*
* @param type - The event name from `AppEvents`.
* @param handler - Callback receiving the typed payload. May be updated on re-render.
*
* @example
* ```tsx
* useAppEvent('AUTH:PROFILE_UPDATED', (profile) => {
* console.log(profile.name); // fully typed, auto-cleaned on unmount
* });
* ```
*/
export function useAppEvent<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): void {
// Always keep the latest handler in a ref to avoid stale closures
// and prevent re-subscription on every render.
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
// Create a stable delegate that forwards to the latest handler ref
const delegate = (event: AppEvents[K]) => {
handlerRef.current(event);
};
eventBus.on(type, delegate);
// Cleanup: unsubscribe when the component unmounts or `type` changes.
// This is the critical memory-leak prevention mechanism.
return () => {
eventBus.off(type, delegate);
};
}, [type]);
}
// ─── usePublishEvent ────────────────────────────────────────────
/**
* Returns a strictly typed `publish` function.
*
* The returned function is referentially stable (memoized) so it
* can be safely passed as a prop or used in dependency arrays
* without causing unnecessary re-renders.
*
* @example
* ```tsx
* const publish = usePublishEvent();
*
* const handleClick = () => {
* publish('DEVICE:PRINT_RECEIPT', {
* receiptId: '001',
* items: [{ name: 'Widget', qty: 2, price: 9.99 }],
* total: 19.98,
* cashierName: 'Firman',
* timestamp: Date.now(),
* });
* };
* ```
*/
export function usePublishEvent(): typeof busPublish {
return useCallback(busPublish, []);
}
+14
View File
@@ -0,0 +1,14 @@
// ─── Event Registry ─────────────────────────────────────────────
export type {
AppEvents,
PrintReceiptPayload,
StockUpdatePayload,
ProfileUpdatedPayload,
ReceiptItem,
} from './events.registry';
// ─── Event Bus (Core) ───────────────────────────────────────────
export { eventBus, publish, subscribe } from './event-bus';
// ─── React Hooks ────────────────────────────────────────────────
export { useAppEvent, usePublishEvent } from './hooks';