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

This commit is contained in:
Firman Ramdhani
2026-05-28 11:51:43 +07:00
parent 1c6d76bf4f
commit df229c9984
13 changed files with 264 additions and 140 deletions
+79 -26
View File
@@ -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<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]
B[Profile Settings]
C[WebSocket Client]
end
subgraph Core
E((Event Bus<br/>mitt))
R[[AppEvents<br/>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(),
});
};