refactor: decouple core-events registry by implementing consumer-side TypeScript module augmentation and reorganizing showcase demos
This commit is contained in:
+1
-1
@@ -36,7 +36,7 @@ interface StorageSyncListenerProps {
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* 3. For bidirectional sync (storage → event), consider adding a
|
* 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.
|
* the app reads the profile from IndexedDB on boot.
|
||||||
*
|
*
|
||||||
* 4. Error handling: In production, wrap the `setItem` call in a
|
* 4. Error handling: In production, wrap the `setItem` call in a
|
||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
|
|
||||||
// ── Showcase Components ──────────────────────────────────────────
|
// ── Showcase Components ──────────────────────────────────────────
|
||||||
import { CashierUI } from './printer/CashierUI';
|
import { CashierUI } from './printer/cashier.ui';
|
||||||
import { PrinterListener } from './printer/PrinterListener';
|
import { PrinterListener } from './printer/printer.listener';
|
||||||
import { LiveStockGrid } from './stock-grid/LiveStockGrid';
|
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
|
||||||
import { ProfileSettingsUI } from './auth-sync/ProfileSettingsUI';
|
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
||||||
import { StorageSyncListener } from './auth-sync/StorageSyncListener';
|
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
||||||
|
|
||||||
// ─── Events Demo Page ───────────────────────────────────────────
|
// ─── Events Demo Page ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
|
import { Button, Group, Badge, Text, Stack } from '@repo/ui/components';
|
||||||
import { StockRow } from './StockRow';
|
import { StockRow } from './stock-row.ui';
|
||||||
import { generateStockIds, startMockWebSocket } from './MockWebSocket';
|
import { generateStockIds, startMockWebSocket } from './mock-websocket.service';
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────
|
||||||
|
|
||||||
Vendored
+76
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
`@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:
|
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.
|
- **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.
|
- **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.
|
- **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
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Publishers
|
subgraph "@repo/core-events (Pure Tool)"
|
||||||
|
R["AppEventRegistry<br/>(empty interface)"]
|
||||||
|
T["AppEvents = mapped type"]
|
||||||
|
E((Event Bus<br/>mitt))
|
||||||
|
H[useAppEvent / usePublishEvent]
|
||||||
|
R --> T --> E
|
||||||
|
E --> H
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "apps/web (App Autonomy)"
|
||||||
|
D["events.d.ts<br/>declare module augmentation"]
|
||||||
A[Cashier UI]
|
A[Cashier UI]
|
||||||
B[Profile Settings]
|
B[Profile Settings]
|
||||||
C[WebSocket Client]
|
C[WebSocket Client]
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Core
|
|
||||||
E((Event Bus<br/>mitt))
|
|
||||||
R[[AppEvents<br/>Registry]] -.-> E
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Subscribers
|
|
||||||
X[Electron IPC Bridge]
|
X[Electron IPC Bridge]
|
||||||
Y[IndexedDB Sync]
|
Y[IndexedDB Sync]
|
||||||
Z[Stock Grid Row]
|
Z[Stock Grid Row]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
D -. "merges into" .-> R
|
||||||
|
|
||||||
A -- "DEVICE:PRINT_RECEIPT" --> E
|
A -- "DEVICE:PRINT_RECEIPT" --> E
|
||||||
B -- "AUTH:PROFILE_UPDATED" --> E
|
B -- "AUTH:PROFILE_UPDATED" --> E
|
||||||
C -- "WS:STOCK_UPDATE" --> E
|
C -- "WS:STOCK_UPDATE" --> E
|
||||||
@@ -45,38 +51,82 @@ graph TD
|
|||||||
|
|
||||||
style E fill:#4263eb,color:#fff,stroke:#fff
|
style E fill:#4263eb,color:#fff,stroke:#fff
|
||||||
style R fill:#2b8a3e,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
|
```typescript
|
||||||
// packages/core-events/src/events.registry.ts
|
// apps/web/src/types/events.d.ts
|
||||||
|
|
||||||
export interface CheckoutPayload {
|
// 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;
|
orderId: string;
|
||||||
total: number;
|
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 = {
|
function OrderTracker() {
|
||||||
// Existing events...
|
// ✅ payload is fully typed as OrderPayload
|
||||||
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
|
useAppEvent('STORE:ORDER_PLACED', (payload) => {
|
||||||
|
console.log(payload.orderId); // string
|
||||||
// Your new event:
|
});
|
||||||
'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
|
}
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
## 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)
|
### Example 1: Hardware Abstraction (Cross-Platform)
|
||||||
|
|
||||||
@@ -97,7 +147,8 @@ export function CashierUI() {
|
|||||||
receiptId: 'RCP-123',
|
receiptId: 'RCP-123',
|
||||||
items: [...],
|
items: [...],
|
||||||
total: 45.00,
|
total: 45.00,
|
||||||
cashierName: 'Firman'
|
cashierName: 'Firman',
|
||||||
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,6 +249,8 @@ export function ProfileSettingsUI() {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
name: 'Firman',
|
name: 'Firman',
|
||||||
email: 'firman@eigen.co.id',
|
email: 'firman@eigen.co.id',
|
||||||
|
avatar: 'https://example.com/avatar.png',
|
||||||
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,40 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { eventBus, publish, subscribe } from './event-bus';
|
import { eventBus, publish, subscribe } from './event-bus';
|
||||||
import { useAppEvent, usePublishEvent } from './hooks';
|
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 ────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,8 +75,8 @@ describe('Event Bus (Core)', () => {
|
|||||||
it('publishes and subscribes to a typed event', () => {
|
it('publishes and subscribes to a typed event', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
|
|
||||||
subscribe('WS:STOCK_UPDATE', handler);
|
subscribe('TEST:STOCK_UPDATE', handler);
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
|
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
|
||||||
@@ -53,9 +86,9 @@ describe('Event Bus (Core)', () => {
|
|||||||
const handler1 = vi.fn();
|
const handler1 = vi.fn();
|
||||||
const handler2 = vi.fn();
|
const handler2 = vi.fn();
|
||||||
|
|
||||||
subscribe('AUTH:PROFILE_UPDATED', handler1);
|
subscribe('TEST:PROFILE_UPDATED', handler1);
|
||||||
subscribe('AUTH:PROFILE_UPDATED', handler2);
|
subscribe('TEST:PROFILE_UPDATED', handler2);
|
||||||
publish('AUTH:PROFILE_UPDATED', mockProfile);
|
publish('TEST:PROFILE_UPDATED', mockProfile);
|
||||||
|
|
||||||
expect(handler1).toHaveBeenCalledTimes(1);
|
expect(handler1).toHaveBeenCalledTimes(1);
|
||||||
expect(handler2).toHaveBeenCalledTimes(1);
|
expect(handler2).toHaveBeenCalledTimes(1);
|
||||||
@@ -65,10 +98,10 @@ describe('Event Bus (Core)', () => {
|
|||||||
const stockHandler = vi.fn();
|
const stockHandler = vi.fn();
|
||||||
const profileHandler = vi.fn();
|
const profileHandler = vi.fn();
|
||||||
|
|
||||||
subscribe('WS:STOCK_UPDATE', stockHandler);
|
subscribe('TEST:STOCK_UPDATE', stockHandler);
|
||||||
subscribe('AUTH:PROFILE_UPDATED', profileHandler);
|
subscribe('TEST:PROFILE_UPDATED', profileHandler);
|
||||||
|
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
|
|
||||||
expect(stockHandler).toHaveBeenCalledTimes(1);
|
expect(stockHandler).toHaveBeenCalledTimes(1);
|
||||||
expect(profileHandler).not.toHaveBeenCalled();
|
expect(profileHandler).not.toHaveBeenCalled();
|
||||||
@@ -77,15 +110,15 @@ describe('Event Bus (Core)', () => {
|
|||||||
it('unsubscribes correctly via returned function', () => {
|
it('unsubscribes correctly via returned function', () => {
|
||||||
const handler = vi.fn();
|
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);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Unsubscribe
|
// Unsubscribe
|
||||||
unsub();
|
unsub();
|
||||||
|
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
// Should still be 1, not 2
|
// Should still be 1, not 2
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -93,8 +126,8 @@ describe('Event Bus (Core)', () => {
|
|||||||
it('handles events with undefined payloads', () => {
|
it('handles events with undefined payloads', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
|
|
||||||
subscribe('APP:INITIALIZED', handler);
|
subscribe('TEST:INITIALIZED', handler);
|
||||||
publish('APP:INITIALIZED', undefined);
|
publish('TEST:INITIALIZED', undefined);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
expect(handler).toHaveBeenCalledWith(undefined);
|
expect(handler).toHaveBeenCalledWith(undefined);
|
||||||
@@ -102,10 +135,10 @@ describe('Event Bus (Core)', () => {
|
|||||||
|
|
||||||
it('handles rapid-fire events without loss', () => {
|
it('handles rapid-fire events without loss', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
subscribe('WS:STOCK_UPDATE', handler);
|
subscribe('TEST:STOCK_UPDATE', handler);
|
||||||
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
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);
|
expect(handler).toHaveBeenCalledTimes(1000);
|
||||||
@@ -126,10 +159,10 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
it('subscribes on mount and receives events', () => {
|
it('subscribes on mount and receives events', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
|
|
||||||
renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
|
renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
@@ -139,11 +172,11 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
|
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
|
||||||
const handler = vi.fn();
|
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
|
// Event should be received while mounted
|
||||||
act(() => {
|
act(() => {
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
});
|
});
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -152,7 +185,7 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
|
|
||||||
// Event should NOT be received after unmount
|
// Event should NOT be received after unmount
|
||||||
act(() => {
|
act(() => {
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Still 1, proving the handler was properly cleaned up
|
// Still 1, proving the handler was properly cleaned up
|
||||||
@@ -164,20 +197,20 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
|
|
||||||
// Mount and unmount 100 times
|
// Mount and unmount 100 times
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
|
const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
|
||||||
unmount();
|
unmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// After 100 cycles, emit one event
|
// After 100 cycles, emit one event
|
||||||
act(() => {
|
act(() => {
|
||||||
publish('WS:STOCK_UPDATE', mockStockUpdate);
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should be 0 — all handlers should have been cleaned up
|
// Should be 0 — all handlers should have been cleaned up
|
||||||
expect(handler).toHaveBeenCalledTimes(0);
|
expect(handler).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
// Verify the handler map is empty for this event type
|
// 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);
|
expect(!handlers || handlers.length === 0).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +219,7 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ value }: { value: string }) =>
|
({ value }: { value: string }) =>
|
||||||
useAppEvent('AUTH:PROFILE_UPDATED', () => {
|
useAppEvent('TEST:PROFILE_UPDATED', () => {
|
||||||
capturedValue = value;
|
capturedValue = value;
|
||||||
}),
|
}),
|
||||||
{ initialProps: { value: 'initial' } },
|
{ initialProps: { value: 'initial' } },
|
||||||
@@ -196,7 +229,7 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
rerender({ value: 'updated' });
|
rerender({ value: 'updated' });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
publish('AUTH:PROFILE_UPDATED', mockProfile);
|
publish('TEST:PROFILE_UPDATED', mockProfile);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should capture the LATEST value, not the stale 'initial'
|
// Should capture the LATEST value, not the stale 'initial'
|
||||||
@@ -210,7 +243,7 @@ describe('useAppEvent (React Hook)', () => {
|
|||||||
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ handler }: { handler: () => void }) =>
|
({ handler }: { handler: () => void }) =>
|
||||||
useAppEvent('APP:INITIALIZED', handler),
|
useAppEvent('TEST:INITIALIZED', handler),
|
||||||
{ initialProps: { handler: vi.fn() } },
|
{ initialProps: { handler: vi.fn() } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -240,12 +273,12 @@ describe('usePublishEvent (React Hook)', () => {
|
|||||||
|
|
||||||
it('returns a working publish function', () => {
|
it('returns a working publish function', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
subscribe('AUTH:PROFILE_UPDATED', handler);
|
subscribe('TEST:PROFILE_UPDATED', handler);
|
||||||
|
|
||||||
const { result } = renderHook(() => usePublishEvent());
|
const { result } = renderHook(() => usePublishEvent());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current('AUTH:PROFILE_UPDATED', mockProfile);
|
result.current('TEST:PROFILE_UPDATED', mockProfile);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -1,82 +1,50 @@
|
|||||||
// ─── Event Payload Types ────────────────────────────────────────
|
// ─── Application Event Registry (IoC Pattern) ──────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receipt line item for the DEVICE:PRINT_RECEIPT event.
|
* Open interface for application event registration.
|
||||||
*/
|
|
||||||
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
|
* **This interface is intentionally empty at the core level.**
|
||||||
* type. This provides:
|
|
||||||
*
|
*
|
||||||
* 1. **Compile-time safety** — typos in event names are caught by TS.
|
* Each consuming app (`apps/web`, `apps/landing`, etc.) is responsible
|
||||||
* 2. **Payload validation** — publishers and subscribers agree on shape.
|
* for registering its own events using TypeScript Declaration Merging
|
||||||
* 3. **Discoverability** — `Ctrl+Click` any event to find its contract.
|
* (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
|
* ## How to register events
|
||||||
* type using intersection:
|
*
|
||||||
|
* Create a `.d.ts` file anywhere in your app's `src/` folder:
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* // In your feature module types:
|
* // apps/web/src/types/events.d.ts
|
||||||
* type InventoryEvents = {
|
* declare module '@repo/core-events' {
|
||||||
* 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number };
|
* interface AppEventRegistry {
|
||||||
* };
|
* 'DOMAIN:EVENT_NAME': { payload: string };
|
||||||
* // Then merge into AppEvents in this file.
|
* }
|
||||||
|
* }
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* TypeScript will automatically merge all augmentations into a single
|
||||||
|
* `AppEventRegistry` interface, giving you full autocomplete and
|
||||||
|
* compile-time type safety across the entire app — without the core
|
||||||
|
* package knowing anything about your events.
|
||||||
|
*
|
||||||
|
* **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface AppEventRegistry {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved event map consumed by `mitt` and all public APIs.
|
||||||
|
*
|
||||||
|
* This type alias bridges the open `AppEventRegistry` interface
|
||||||
|
* (which supports declaration merging) to the `Record<string, unknown>`
|
||||||
|
* constraint that `mitt` requires.
|
||||||
|
*
|
||||||
|
* You should never reference this type directly in consumer code.
|
||||||
|
* Use `AppEventRegistry` for augmentation and let the core handle the rest.
|
||||||
*/
|
*/
|
||||||
export type AppEvents = {
|
export type AppEvents = {
|
||||||
// ── Device / Hardware ───────────────────────────────────────────
|
[K in keyof AppEventRegistry]: AppEventRegistry[K];
|
||||||
'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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
// ─── Event Registry ─────────────────────────────────────────────
|
// ─── Event Registry ─────────────────────────────────────────────
|
||||||
export type {
|
export type { AppEventRegistry, AppEvents } from './events.registry';
|
||||||
AppEvents,
|
|
||||||
PrintReceiptPayload,
|
|
||||||
StockUpdatePayload,
|
|
||||||
ProfileUpdatedPayload,
|
|
||||||
ReceiptItem,
|
|
||||||
} from './events.registry';
|
|
||||||
|
|
||||||
// ─── Event Bus (Core) ───────────────────────────────────────────
|
// ─── Event Bus (Core) ───────────────────────────────────────────
|
||||||
export { eventBus, publish, subscribe } from './event-bus';
|
export { eventBus, publish, subscribe } from './event-bus';
|
||||||
|
|||||||
Reference in New Issue
Block a user