+ );
+});
diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx
index fdcd726..5cf52bf 100644
--- a/apps/web/src/apps/showcase/showcase-view.tsx
+++ b/apps/web/src/apps/showcase/showcase-view.tsx
@@ -21,6 +21,7 @@ import {
} from '@repo/ui/components';
import PrinterList from './printer-list';
import ExamplePage from './example/example.page';
+import EventsDemoPage from './events-demo';
interface ShowcaseViewProps {
colorScheme: ColorSchemeType;
@@ -219,6 +220,11 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
+
+ {/* =========================================
+ EVENT BUS SHOWCASE
+ ========================================= */}
+
);
diff --git a/packages/core-events/README.md b/packages/core-events/README.md
new file mode 100644
index 0000000..3b3c46a
--- /dev/null
+++ b/packages/core-events/README.md
@@ -0,0 +1,222 @@
+# @repo/core-events
+
+[โ Back to Root](../../README.md)
+
+## 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.
+
+By routing communication through a centralized event bus, we achieve:
+- **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.
+
+---
+
+## Architecture
+
+```mermaid
+graph TD
+ subgraph Publishers
+ 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
+
+ A -- "DEVICE:PRINT_RECEIPT" --> E
+ B -- "AUTH:PROFILE_UPDATED" --> E
+ C -- "WS:STOCK_UPDATE" --> E
+
+ E -.-> X
+ E -.-> Y
+ E -.-> Z
+
+ style E fill:#4263eb,color:#fff,stroke:#fff
+ style R fill:#2b8a3e,color:#fff,stroke:#fff
+```
+
+---
+
+## Defining Events
+
+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.
+
+To add a new event, simply extend the `AppEvents` type:
+
+```typescript
+// packages/core-events/src/events.registry.ts
+
+export interface CheckoutPayload {
+ orderId: string;
+ total: number;
+}
+
+export type AppEvents = {
+ // Existing events...
+ 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
+
+ // Your new event:
+ 'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
+};
+```
+
+---
+
+## Usage Examples
+
+Here are three real-world architectural patterns powered by the Event Bus.
+
+### 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.00,
+ cashierName: 'Firman'
+ });
+ };
+
+ return ;
+}
+```
+
+**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 (
+
+
+ {stockIds.map((id) => (
+
+ ))}
+
+
+ );
+}
+```
+
+**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 (
+
+
{stockId}
+
{data?.price}
+
+ );
+});
+```
+
+---
+
+### 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.
+
+**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',
+ });
+ };
+
+ return ;
+}
+```
+
+**Subscriber (Storage Sync Listener)**:
+```tsx
+import { useAppEvent } from '@repo/core-events';
+import { secureIndexedDB } from '@repo/core-storage';
+
+export function StorageSyncListener() {
+ 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(console.error);
+ });
+
+ return null;
+}
+```
diff --git a/packages/core-events/package.json b/packages/core-events/package.json
new file mode 100644
index 0000000..aad0b36
--- /dev/null
+++ b/packages/core-events/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@repo/core-events",
+ "version": "0.0.0",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "license": "MIT",
+ "scripts": {
+ "lint": "eslint \"**/*.ts\" \"**/*.tsx\"",
+ "test": "vitest run",
+ "test:watch": "vitest --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "mitt": "^3.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "@testing-library/react": "^16.3.0",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "eslint": "^8.57.1",
+ "jsdom": "^26.1.0",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "typescript": "5.5.4",
+ "vitest": "^4.0.17"
+ }
+}
diff --git a/packages/core-events/src/event-bus.test.ts b/packages/core-events/src/event-bus.test.ts
new file mode 100644
index 0000000..0e47d92
--- /dev/null
+++ b/packages/core-events/src/event-bus.test.ts
@@ -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);
+ });
+});
diff --git a/packages/core-events/src/event-bus.ts b/packages/core-events/src/event-bus.ts
new file mode 100644
index 0000000..010c097
--- /dev/null
+++ b/packages/core-events/src/event-bus.ts
@@ -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();
+
+// โโโ 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(
+ 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(
+ type: K,
+ handler: (event: AppEvents[K]) => void,
+): () => void {
+ eventBus.on(type, handler);
+ return () => eventBus.off(type, handler);
+}
diff --git a/packages/core-events/src/events.registry.ts b/packages/core-events/src/events.registry.ts
new file mode 100644
index 0000000..229d348
--- /dev/null
+++ b/packages/core-events/src/events.registry.ts
@@ -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 };
+};
diff --git a/packages/core-events/src/hooks.ts b/packages/core-events/src/hooks.ts
new file mode 100644
index 0000000..a3e5fc3
--- /dev/null
+++ b/packages/core-events/src/hooks.ts
@@ -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(
+ 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, []);
+}
diff --git a/packages/core-events/src/index.ts b/packages/core-events/src/index.ts
new file mode 100644
index 0000000..605928a
--- /dev/null
+++ b/packages/core-events/src/index.ts
@@ -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';
diff --git a/packages/core-events/tsconfig.json b/packages/core-events/tsconfig.json
new file mode 100644
index 0000000..b18477f
--- /dev/null
+++ b/packages/core-events/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/react-library.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true
+ }
+}
diff --git a/packages/core-events/vitest.config.ts b/packages/core-events/vitest.config.ts
new file mode 100644
index 0000000..c4588ab
--- /dev/null
+++ b/packages/core-events/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd8343b..87eb7f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -161,6 +161,9 @@ importers:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
+ '@repo/core-events':
+ specifier: workspace:*
+ version: link:../../packages/core-events
'@repo/core-i18n':
specifier: workspace:*
version: link:../../packages/core-i18n
@@ -224,7 +227,7 @@ importers:
version: 5.4.17(@types/node@22.19.3)
vitest:
specifier: ^4.0.17
- version: 4.0.17(@opentelemetry/api@1.9.1)
+ version: 4.0.17(jsdom@26.1.0)
packages/configs/eslint:
dependencies:
@@ -303,6 +306,46 @@ importers:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1)
+ packages/core-events:
+ dependencies:
+ mitt:
+ specifier: ^3.0.1
+ version: 3.0.1
+ devDependencies:
+ '@repo/eslint-config':
+ specifier: workspace:*
+ version: link:../configs/eslint
+ '@repo/typescript-config':
+ specifier: workspace:*
+ version: link:../configs/typescript
+ '@testing-library/react':
+ specifier: ^16.3.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@types/react':
+ specifier: ^19.2.7
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.7)
+ eslint:
+ specifier: ^8.57.1
+ version: 8.57.1
+ jsdom:
+ specifier: ^26.1.0
+ version: 26.1.0
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ typescript:
+ specifier: 5.5.4
+ version: 5.5.4
+ vitest:
+ specifier: ^4.0.17
+ version: 4.0.17(jsdom@26.1.0)
+
packages/core-i18n:
dependencies:
'@repo/core-storage':
@@ -351,7 +394,7 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
- version: 4.0.17(@opentelemetry/api@1.9.1)
+ version: 4.0.17(jsdom@26.1.0)
packages/ui:
dependencies:
@@ -412,7 +455,7 @@ importers:
version: 5.4.17(@types/node@22.19.3)
vitest:
specifier: ^4.0.17
- version: 4.0.17(@opentelemetry/api@1.9.1)
+ version: 4.0.17(jsdom@26.1.0)
packages/utils:
dependencies:
@@ -440,7 +483,7 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
- version: 4.0.17(@opentelemetry/api@1.9.1)
+ version: 4.0.17(jsdom@26.1.0)
packages:
@@ -448,6 +491,16 @@ packages:
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
dev: true
+ /@asamuzakjp/css-color@3.2.0:
+ resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+ dependencies:
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ lru-cache: 10.4.3
+ dev: true
+
/@babel/code-frame@7.27.1:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -636,6 +689,49 @@ packages:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ /@csstools/color-helpers@5.1.0:
+ resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4):
+ resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^3.0.5
+ '@csstools/css-tokenizer': ^3.0.4
+ dependencies:
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ dev: true
+
+ /@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4):
+ resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^3.0.5
+ '@csstools/css-tokenizer': ^3.0.4
+ dependencies:
+ '@csstools/color-helpers': 5.1.0
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ dev: true
+
+ /@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4):
+ resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^3.0.4
+ dependencies:
+ '@csstools/css-tokenizer': 3.0.4
+ dev: true
+
+ /@csstools/css-tokenizer@3.0.4:
+ resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
+ engines: {node: '>=18'}
+ dev: true
+
/@develar/schema-utils@2.6.5:
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
@@ -2823,6 +2919,43 @@ packages:
tailwindcss: 4.1.18
vite: 5.4.17(@types/node@22.19.3)
+ /@testing-library/dom@10.4.1:
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/runtime': 7.28.4
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+ dev: true
+
+ /@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.28.4
+ '@testing-library/dom': 10.4.1
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ dev: true
+
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -2836,6 +2969,10 @@ packages:
dev: false
optional: true
+ /@types/aria-query@5.0.4:
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ dev: true
+
/@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies:
@@ -3771,6 +3908,11 @@ packages:
dependencies:
color-convert: 2.0.1
+ /ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+ dev: true
+
/ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
@@ -3894,6 +4036,12 @@ packages:
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ /aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+ dependencies:
+ dequal: 2.0.3
+ dev: true
+
/aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -4620,6 +4768,14 @@ packages:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
dev: false
+ /cssstyle@4.6.0:
+ resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
+ engines: {node: '>=18'}
+ dependencies:
+ '@asamuzakjp/css-color': 3.2.0
+ rrweb-cssom: 0.8.0
+ dev: true
+
/csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -4627,6 +4783,14 @@ packages:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: false
+ /data-urls@5.0.0:
+ resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
+ engines: {node: '>=18'}
+ dependencies:
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 14.2.0
+ dev: true
+
/data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -4702,6 +4866,10 @@ packages:
dependencies:
ms: 2.1.3
+ /decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ dev: true
+
/decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
dependencies:
@@ -4864,6 +5032,10 @@ packages:
dependencies:
esutils: 2.0.3
+ /dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+ dev: true
+
/dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -5032,6 +5204,11 @@ packages:
graceful-fs: 4.2.11
tapable: 2.3.0
+ /entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+ dev: true
+
/env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -6264,6 +6441,13 @@ packages:
lru-cache: 10.4.3
dev: false
+ /html-encoding-sniffer@4.0.0:
+ resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
+ engines: {node: '>=18'}
+ dependencies:
+ whatwg-encoding: 3.1.1
+ dev: true
+
/html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
dependencies:
@@ -6642,6 +6826,10 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
+ /is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ dev: true
+
/is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -6799,6 +6987,41 @@ packages:
engines: {node: '>=12.0.0'}
dev: true
+ /jsdom@26.1.0:
+ resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+ dependencies:
+ cssstyle: 4.6.0
+ data-urls: 5.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 4.0.0
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ nwsapi: 2.2.23
+ parse5: 7.3.0
+ rrweb-cssom: 0.8.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 5.1.2
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 7.0.0
+ whatwg-encoding: 3.1.1
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 14.2.0
+ ws: 8.19.0
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ dev: true
+
/jsesc@0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
@@ -7130,6 +7353,11 @@ packages:
engines: {node: '>=12'}
dev: true
+ /lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+ dev: true
+
/magic-string@0.27.0:
resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==}
engines: {node: '>=12'}
@@ -7711,6 +7939,10 @@ packages:
yallist: 4.0.0
dev: true
+ /mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+ dev: false
+
/mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -7888,6 +8120,10 @@ packages:
set-blocking: 2.0.0
dev: true
+ /nwsapi@2.2.23:
+ resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
+ dev: true
+
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -8103,6 +8339,12 @@ packages:
type-fest: 3.13.1
dev: false
+ /parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ dependencies:
+ entities: 6.0.1
+ dev: true
+
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -8226,6 +8468,15 @@ packages:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
+ /pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+ dev: true
+
/proc-log@4.2.0:
resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -8388,6 +8639,10 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
+ /react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ dev: true
+
/react-number-format@5.4.4(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
peerDependencies:
@@ -8825,6 +9080,10 @@ packages:
fsevents: 2.3.3
dev: true
+ /rrweb-cssom@0.8.0:
+ resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+ dev: true
+
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
@@ -8885,6 +9144,13 @@ packages:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
+ /saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+ dependencies:
+ xmlchars: 2.2.0
+ dev: true
+
/scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -9389,6 +9655,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ /symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ dev: true
+
/synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -9488,6 +9758,17 @@ packages:
engines: {node: '>=14.0.0'}
dev: true
+ /tldts-core@6.1.86:
+ resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
+ dev: true
+
+ /tldts@6.1.86:
+ resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
+ hasBin: true
+ dependencies:
+ tldts-core: 6.1.86
+ dev: true
+
/tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
dependencies:
@@ -9506,6 +9787,20 @@ packages:
is-number: 7.0.0
dev: false
+ /tough-cookie@5.1.2:
+ resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
+ engines: {node: '>=16'}
+ dependencies:
+ tldts: 6.1.86
+ dev: true
+
+ /tr46@5.1.1:
+ resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+ engines: {node: '>=18'}
+ dependencies:
+ punycode: 2.3.1
+ dev: true
+
/trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
dev: false
@@ -10233,11 +10528,87 @@ packages:
- yaml
dev: true
+ /vitest@4.0.17(jsdom@26.1.0):
+ resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.0.17
+ '@vitest/browser-preview': 4.0.17
+ '@vitest/browser-webdriverio': 4.0.17
+ '@vitest/ui': 4.0.17
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ dependencies:
+ '@vitest/expect': 4.0.17
+ '@vitest/mocker': 4.0.17(vite@7.3.1)
+ '@vitest/pretty-format': 4.0.17
+ '@vitest/runner': 4.0.17
+ '@vitest/snapshot': 4.0.17
+ '@vitest/spy': 4.0.17
+ '@vitest/utils': 4.0.17
+ es-module-lexer: 1.7.0
+ expect-type: 1.3.0
+ jsdom: 26.1.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.0.3
+ vite: 7.3.1
+ why-is-node-running: 2.3.0
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - terser
+ - tsx
+ - yaml
+ dev: true
+
/void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
dev: false
+ /w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+ dependencies:
+ xml-name-validator: 5.0.0
+ dev: true
+
/walk-up-path@3.0.1:
resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==}
dev: false
@@ -10252,10 +10623,36 @@ packages:
resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
dev: false
+ /webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+ dev: true
+
/webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
dev: true
+ /whatwg-encoding@3.1.1:
+ resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+ engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+ dependencies:
+ iconv-lite: 0.6.3
+ dev: true
+
+ /whatwg-mimetype@4.0.0:
+ resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /whatwg-url@14.2.0:
+ resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+ engines: {node: '>=18'}
+ dependencies:
+ tr46: 5.1.1
+ webidl-conversions: 7.0.0
+ dev: true
+
/which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -10380,12 +10777,21 @@ packages:
optional: true
dev: true
+ /xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+ dev: true
+
/xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
requiresBuild: true
dev: true
+ /xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ dev: true
+
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
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 2/6] 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';
From c510feadbb8e79165de4c2f0eae525e93e72d8ae Mon Sep 17 00:00:00 2001
From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com>
Date: Thu, 28 May 2026 12:20:53 +0700
Subject: [PATCH 3/6] docs: update README files for core packages with
architecture diagrams and usage examples
---
packages/core-api/README.md | 124 ++++++++++++++++++--------------
packages/core-events/README.md | 48 +++++++++++--
packages/core-i18n/README.md | 74 ++++++++++++++++++-
packages/core-storage/README.md | 96 +++++++++++++++++++------
4 files changed, 255 insertions(+), 87 deletions(-)
diff --git a/packages/core-api/README.md b/packages/core-api/README.md
index d867f50..9ca1f74 100644
--- a/packages/core-api/README.md
+++ b/packages/core-api/README.md
@@ -1,65 +1,79 @@
-# @repo/core-api
+# Enterprise API Engine (`@repo/core-api`)
-The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine โ consumed by `apps/web`, `apps/landing`, and any future workspace.
+[โ Back to Root](../../README.md)
----
+The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine.
-## Table of Contents
-
-- [Architecture Overview](#architecture-overview)
-- [HTTP Client](#http-client)
-- [Observability](#observability)
-- [Data Services](#data-services)
-- [Application Setup Guide](#application-setup-guide)
-- [Per-Request Telemetry (Escape Hatch)](#per-request-telemetry-escape-hatch)
-- [Error Handling](#error-handling)
-- [Package Exports](#package-exports)
+**This package enforces App Autonomy (IoC).** The core provides the engine and interceptor pipelines, but the consuming applications (`apps/web`, `apps/landing`) inject their own specific configurations, authentication tokens, and error handling behaviors.
---
## Architecture Overview
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ @repo/core-api โ
-โ โ
-โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ โ
-โ โ http-client โ โ observability โ โ data-services โ โ
-โ โ โ โ โ โ โ โ
-โ โ createHttp โโโโโ faroAdapter โ โ BaseRemoteData โ โ
-โ โ Client() โ โ initTelemetry() โ โ Services โ โ
-โ โ โ โ getFaro() โ โ CommonRemoteData โ โ
-โ โ ApiResponse โ โ noopAdapter โ โ Services โ โ
-โ โโโโโโโโฌโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโโโ โ
-โ โ โ โ
-โ โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ โ
-โ โโโโโโโโโโดโโโโโโโโโ โ
-โ โ errors โ โ
-โ โ ApiError โ โ
-โ โ ErrorCodes โ โ
-โ โโโโโโโโโโโโโโโโโโโ โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- โ โ โ
- โผ โผ โผ
- apps/web apps/landing apps/desktop
+```mermaid
+graph TD
+ subgraph Apps ["apps/* (App Autonomy)"]
+ WEB[apps/web]
+ LAND[apps/landing]
+ DESK[apps/desktop]
+ end
+
+ subgraph Core ["@repo/core-api (Engine)"]
+ subgraph HTTP ["http-client"]
+ FACTORY[createHttpClient]
+ end
+ subgraph OBS ["observability"]
+ FARO[faroAdapter]
+ end
+ subgraph DATA ["data-services"]
+ BASE[BaseRemoteDataServices]
+ COMMON[CommonRemoteDataServices]
+ end
+ subgraph ERRORS ["errors"]
+ API_ERR[ApiError]
+ end
+ end
+
+ WEB & LAND & DESK -->|instantiates| FACTORY
+ WEB & LAND & DESK -->|extends| COMMON
+ COMMON -->|executes via| FACTORY
+ FACTORY -.->|reports via| FARO
+ FACTORY -.->|throws| API_ERR
+
+ style Core fill:#f8f9fa,stroke:#ced4da
+ style Apps fill:#e9ecef,stroke:#adb5bd
```
-### Data Flow
+### Data Flow Lifecycle
-Every HTTP request flows through this pipeline:
+Every HTTP request flows through this precise interceptor pipeline:
-```
-Component โ DataService.getMany() โ execute()
- โ httpClient.request()
- โ Request Interceptor:
- 1. faroAdapter.onRequestStart() โ Faro log + optional custom span
- 2. hooks.onRequest() โ App-specific (e.g., Bearer token)
- โ Network (fetch/XHR)
- โ Response Interceptor:
- SUCCESS: faroAdapter.onRequestEnd() โ hooks.onResponse()
- ERROR: faroAdapter.onRequestError() โ hooks.onResponseError()
- โ ApiError.fromAxiosError()
+```mermaid
+sequenceDiagram
+ participant C as UI Component
+ participant S as Data Service
+ participant H as HTTP Client
+ participant F as Faro Adapter
+ participant A as App Hooks (IoC)
+ participant N as Network
+
+ C->>S: getMany()
+ S->>H: request()
+ H->>F: onRequestStart() (Log + Span)
+ H->>A: hooks.onRequest() (Inject Token)
+ A->>N: fetch/XHR
+
+ alt Success
+ N-->>A: 200 OK
+ A->>F: onRequestEnd() (Close Span)
+ F->>A: hooks.onResponse()
+ A-->>S: return data
+ else Error
+ N-->>A: 401 / 500
+ A->>F: onRequestError() (Log Error)
+ F->>A: hooks.onResponseError() (Redirect/Refresh)
+ A-->>S: throw ApiError
+ end
```
> [!IMPORTANT]
@@ -143,10 +157,10 @@ import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
appName: 'fe-monorepo-web',
appVersion: '1.0.0',
- telemetryUrl: 'https://telemetry.eigen.co.id/collect',
+ telemetryUrl: '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)',
environment: 'production',
// Optional: direct OTLP export to Grafana Tempo
- otlpTraceUrl: 'https://telemetry.eigen.co.id/v1/traces',
+ otlpTraceUrl: '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)',
});
```
@@ -254,8 +268,8 @@ import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
- telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
- otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces',
+ telemetryUrl: import.meta.env.VITE_FARO_URL || '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)',
+ otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)',
environment: import.meta.env.VITE_ENV || 'development',
});
@@ -422,4 +436,4 @@ try {
| `@repo/core-api/observability` | `faroAdapter`, `noopObservabilityAdapter`, `IObservabilityAdapter`, `initTelemetry`, `getFaro`, `TelemetryConfig` |
| `@repo/core-api/observability/setup` | `initTelemetry`, `getFaro`, `TelemetryConfig` |
| `@repo/core-api/data-services` | `BaseRemoteDataServices`, `CommonRemoteDataServices`, types, constants |
-| `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` |
+| `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` |
\ No newline at end of file
diff --git a/packages/core-events/README.md b/packages/core-events/README.md
index 0c9dcc5..49e9f73 100644
--- a/packages/core-events/README.md
+++ b/packages/core-events/README.md
@@ -89,6 +89,12 @@ declare module '@repo/core-events' {
'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 };
}
}
```
@@ -102,7 +108,7 @@ function CheckoutButton() {
const publish = usePublishEvent();
// โ 'STORE:ORDER_PLACED' autocompletes.
// โ Payload shape is enforced by TypeScript.
- publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [...] });
+ publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [] });
}
function OrderTracker() {
@@ -124,6 +130,29 @@ function OrderTracker() {
---
+## 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.
@@ -145,7 +174,7 @@ export function CashierUI() {
// Fire and forget. Zero knowledge of how printing actually happens.
publish('DEVICE:PRINT_RECEIPT', {
receiptId: 'RCP-123',
- items: [...],
+ items: [],
total: 45.00,
cashierName: 'Firman',
timestamp: Date.now(),
@@ -235,7 +264,7 @@ export const StockRow = memo(function StockRow({ stockId }) {
**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.
+**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
@@ -249,7 +278,7 @@ export function ProfileSettingsUI() {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
- avatar: 'https://example.com/avatar.png',
+ avatar: '[https://example.com/avatar.png](https://example.com/avatar.png)',
updatedAt: Date.now(),
});
};
@@ -260,16 +289,21 @@ export function ProfileSettingsUI() {
**Subscriber (Storage Sync Listener)**:
```tsx
-import { useAppEvent } from '@repo/core-events';
+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(console.error);
+ secureIndexedDB.setItem('user_profile', payload).catch((error) => {
+ // Escalate to global error handler instead of swallowing it
+ publish('SYSTEM:ERROR', { source: 'StorageSyncListener', error });
+ });
});
return null;
}
-```
+```
\ No newline at end of file
diff --git a/packages/core-i18n/README.md b/packages/core-i18n/README.md
index c34d7ba..944d53b 100644
--- a/packages/core-i18n/README.md
+++ b/packages/core-i18n/README.md
@@ -1,5 +1,7 @@
# Enterprise i18n Architecture (`@repo/core-i18n`)
+[โ Back to Root](../../README.md)
+
A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
It uses a **Hybrid Namespace Strategy**:
@@ -10,6 +12,43 @@ This architecture strictly adheres to **Inversion of Control (IoC)**. The core e
---
+## Overview Architecture
+
+```mermaid
+graph TD
+ subgraph Apps ["apps/* (App Autonomy)"]
+ UI[React Components]
+ DICT[Feature Dictionaries e.g., booking.json]
+ end
+
+ subgraph Core ["@repo/core-i18n (Engine)"]
+ I18N((i18next Instance))
+ STORE[(core-storage)]
+ COMMON[Common Vocabulary]
+ end
+
+ subgraph Backend ["Backend API"]
+ SYNC[Language Sync Endpoint]
+ TENANT[Tenant Config Endpoint]
+ end
+
+ UI -->|uses useTranslation| I18N
+ DICT -.->|lazy loads| I18N
+ COMMON -->|preloads| I18N
+ I18N <-->|reads/persists| STORE
+
+ I18N -->|changeLanguage sync| SYNC
+ SYNC -.->|fails? rollback| I18N
+
+ TENANT -.->|applyTenantOverrides| I18N
+
+ style I18N fill:#4263eb,color:#fff,stroke:#fff
+ style Apps fill:#f8f9fa,stroke:#ced4da
+ style Core fill:#f8f9fa,stroke:#ced4da
+```
+
+---
+
## 1. App-Level Setup (Bootstrap)
Initialize the engine *before* your React application mounts to prevent UI flashing.
@@ -84,9 +123,38 @@ export default function BookingFeature() {
}
```
+**3. Dynamic Variables (Interpolation):**
+```json
+// booking.json
+{
+ "messages": {
+ "welcome": "Welcome back, {{name}}! You have {{count}} new bookings."
+ }
+}
+```
+```tsx
+// Inside component
+