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

This commit is contained in:
Firman Ramdhani
2026-05-28 11:20:35 +07:00
parent 3621f837f6
commit 6fde65f04e
21 changed files with 2021 additions and 7 deletions
+22 -3
View File
@@ -269,7 +269,26 @@ Provides a Hybrid Namespace Architecture combining a centralized i18n engine wit
---
### 8. `packages/utils`
### 8. `packages/core-events`
The **decoupled Nervous System** for the monorepo.
Provides a highly performant, strictly typed Event Bus (Pub/Sub) powered by `mitt`. It allows independent modules to communicate seamlessly without tightly coupling their codebases or triggering expensive global React tree re-renders.
**Key Capabilities**:
| Feature | Description |
|---|---|
| 🧩 Zero Coupling | Publishers and subscribers interact via blind events, eliminating direct module imports and circular dependencies. |
| ⚡ Extreme Performance | Enables targeted DOM updates for high-frequency data streams (e.g., WebSockets) without re-rendering parent components. |
| 🧹 Memory Safety | Native `useAppEvent` hook automatically unsubscribes on component unmount, preventing SPA memory leaks. |
| 🛡️ Strict Contracts | Centralized `events.registry.ts` enforces payload shapes via TypeScript, ensuring cross-module data safety. |
**Documentation**: [README.md](packages/core-events/README.md)
---
### 9. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest.
@@ -277,7 +296,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
---
### 9. `packages/ui`
### 10. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -286,7 +305,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
---
### 10. `packages/configs`
### 11. `packages/configs`
Single source of truth for tooling configuration.
+1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-events": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*",
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events';
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
// ─── ProfileSettingsUI ──────────────────────────────────────────
/**
* A profile settings form that publishes `AUTH:PROFILE_UPDATED`
* when the user saves changes.
*
* **Decoupling principle:**
* This component doesn't know about IndexedDB, localStorage,
* or any storage mechanism. It simply announces that the profile
* has been updated. Any number of listeners can react to this
* event independently:
*
* - `StorageSyncListener` persists to IndexedDB
* - A hypothetical `AnalyticsListener` could send to Mixpanel
* - A hypothetical `AvatarCacheListener` could pre-warm a CDN
*
* All without modifying this component.
*/
export function ProfileSettingsUI() {
const publish = usePublishEvent();
const [name, setName] = useState('Firman Ramdhani');
const [email, setEmail] = useState('firman@eigen.co.id');
const [avatar, setAvatar] = useState('https://ui-avatars.com/api/?name=FM&background=4263eb&color=fff');
const [saveCount, setSaveCount] = useState(0);
const handleSave = () => {
publish('AUTH:PROFILE_UPDATED', {
id: 'user-1',
name,
email,
avatar,
updatedAt: Date.now(),
});
setSaveCount((c) => c + 1);
};
return (
<Stack gap="sm">
<Group grow align="flex-start">
<TextInput label="Full Name" value={name} onChange={(e) => setName(e.currentTarget.value)} size="sm" />
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} size="sm" />
</Group>
<TextInput label="Avatar URL" value={avatar} onChange={(e) => setAvatar(e.currentTarget.value)} size="sm" />
<Group>
<Button variant="filled" color="brand" onClick={handleSave}>
💾 Save Profile
</Button>
{saveCount > 0 && (
<Badge color="success" variant="light">
Synced {saveCount}×
</Badge>
)}
</Group>
</Stack>
);
}
@@ -0,0 +1,64 @@
import { useAppEvent } from '@repo/core-events';
import type { ProfileUpdatedPayload } from '@repo/core-events';
import { secureIndexedDB } from '@repo/core-storage';
// ─── Props ──────────────────────────────────────────────────────
interface StorageSyncListenerProps {
/** Callback to log messages to the parent demo UI. */
onLog: (message: string) => void;
}
// ─── StorageSyncListener ────────────────────────────────────────
/**
* Headless component that listens to `AUTH:PROFILE_UPDATED` events
* and persists the profile data to IndexedDB via `@repo/core-storage`.
*
* This component renders nothing — it is purely a side-effect listener.
* Mount it anywhere in the React tree; it will auto-cleanup on unmount.
*
* **Architecture notes for production use:**
*
* 1. The `StorageKey` registry in `@repo/core-storage` should include
* a `USER_PROFILE` key (which it already does — see `storage.key.ts`).
* This means `secureIndexedDB.setItem('user_profile', payload)` will
* automatically encrypt the data at rest because `user_profile` is
* listed in `ENCRYPTED_KEYS`.
*
* 2. If you need to store additional event-driven data, extend `StorageKey`:
* ```ts
* // In packages/core-storage/src/storage.key.ts:
* export const StorageKey = {
* ...existing,
* LAST_PROFILE_SYNC: 'last_profile_sync',
* } as const;
* ```
*
* 3. For bidirectional sync (storage → event), consider adding a
* `STORAGE:PROFILE_LOADED` event to `AppEvents` that fires when
* the app reads the profile from IndexedDB on boot.
*
* 4. Error handling: In production, wrap the `setItem` call in a
* retry mechanism or queue failed writes to a dead-letter store.
*/
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
useAppEvent('AUTH:PROFILE_UPDATED', (payload: ProfileUpdatedPayload) => {
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
// Persist to IndexedDB via @repo/core-storage.
// Uses StorageKey.USER_PROFILE ('user_profile') which is in ENCRYPTED_KEYS,
// so the data will be AES-encrypted at rest automatically.
secureIndexedDB
.setItem('user_profile', payload)
.then(() => {
onLog(`✅ Profile persisted to IndexedDB (key: "user_profile", encrypted: true)`);
})
.catch((err: Error) => {
onLog(`❌ IndexedDB write failed: ${err.message}`);
});
});
// Headless — renders nothing
return null;
}
@@ -0,0 +1,166 @@
import { useState, useRef, useEffect } from 'react';
import {
Card,
Title,
Text,
Stack,
Badge,
Divider,
} 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';
// ─── Events Demo Page ───────────────────────────────────────────
/**
* Orchestrator page for all three Event Bus showcase demos.
*
* This component is completely self-contained within the
* `events-demo/` folder and does not leak state or side-effects
* into the rest of the application.
*/
export default function EventsDemoPage() {
// ── Showcase 1: Printer status feedback ──────────────────────
const [printerLog, setPrinterLog] = useState<string[]>([]);
// ── Showcase 3: Storage sync status feedback ─────────────────
const [syncLog, setSyncLog] = useState<string[]>([]);
// ── Render counter to prove this parent is stable ────────────
const renderCount = useRef(0);
renderCount.current += 1;
// Keep log sizes bounded
useEffect(() => {
if (printerLog.length > 20) setPrinterLog((prev) => prev.slice(-20));
}, [printerLog.length]);
useEffect(() => {
if (syncLog.length > 20) setSyncLog((prev) => prev.slice(-20));
}, [syncLog.length]);
return (
<Stack gap="xl">
<div>
<Title order={2}>🔌 Event Bus Showcase</Title>
<Text size="sm" c="dimmed">
Three real-world demos of <code>@repo/core-events</code> zero coupling, strict typing, high performance.
</Text>
<Badge color="brand" variant="light" mt="xs">
Parent render count: {renderCount.current}
</Badge>
</div>
{/* ═══════════════════════════════════════════════════════════
SHOWCASE 1: Cross-Platform Printer Abstraction
═══════════════════════════════════════════════════════════ */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="xs">
🖨 Showcase 1: Cross-Platform Printer Abstraction
</Title>
<Text size="sm" c="dimmed" mb="md">
The CashierUI publishes a <code>DEVICE:PRINT_RECEIPT</code> event.
A headless PrinterListener decides whether to use Electron IPC or browser print.
</Text>
{/* Headless listener — renders nothing visible */}
<PrinterListener
onLog={(msg) => setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
/>
<CashierUI />
{printerLog.length > 0 && (
<>
<Divider my="sm" />
<Text size="sm" fw={600}>
📋 Printer Log:
</Text>
<div
style={{
maxHeight: 120,
overflow: 'auto',
fontSize: 12,
fontFamily: 'monospace',
background: 'var(--mantine-color-dark-7, #1a1b1e)',
color: 'var(--mantine-color-green-4, #69db7c)',
padding: 8,
borderRadius: 6,
}}
>
{printerLog.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</>
)}
</Card>
{/* ═══════════════════════════════════════════════════════════
SHOWCASE 2: Extreme Performance — Live Stock Grid
═══════════════════════════════════════════════════════════ */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="xs">
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
</Title>
<Text size="sm" c="dimmed" mb="md">
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
Each StockRow subscribes to the global event but only updates when{' '}
<code>payload.id === row.id</code>. The parent grid never re-renders.
</Text>
<LiveStockGrid />
</Card>
{/* ═══════════════════════════════════════════════════════════
SHOWCASE 3: Auth/Profile → IndexedDB Sync
═══════════════════════════════════════════════════════════ */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="xs">
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
</Title>
<Text size="sm" c="dimmed" mb="md">
ProfileSettingsUI publishes <code>AUTH:PROFILE_UPDATED</code>.
A headless StorageSyncListener persists it to IndexedDB via <code>secureIndexedDB</code>.
</Text>
{/* Headless listener — renders nothing visible */}
<StorageSyncListener
onLog={(msg) => setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])}
/>
<ProfileSettingsUI />
{syncLog.length > 0 && (
<>
<Divider my="sm" />
<Text size="sm" fw={600}>
📋 Storage Sync Log:
</Text>
<div
style={{
maxHeight: 120,
overflow: 'auto',
fontSize: 12,
fontFamily: 'monospace',
background: 'var(--mantine-color-dark-7, #1a1b1e)',
color: 'var(--mantine-color-blue-4, #4dabf7)',
padding: 8,
borderRadius: 6,
}}
>
{syncLog.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</>
)}
</Card>
</Stack>
);
}
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events';
import type { ReceiptItem } from '@repo/core-events';
import {
Button,
Group,
Stack,
Text,
TextInput,
Table,
Badge,
} from '@repo/ui/components';
// ─── Mock Receipt Data ──────────────────────────────────────────
const DEMO_ITEMS: ReceiptItem[] = [
{ name: 'Espresso', qty: 2, price: 3.5 },
{ name: 'Croissant', qty: 1, price: 4.25 },
{ name: 'Orange Juice', qty: 3, price: 2.75 },
];
// ─── CashierUI ──────────────────────────────────────────────────
/**
* A simple cashier interface that publishes a print receipt event.
*
* This component has ZERO knowledge of how printing works.
* It simply fires `DEVICE:PRINT_RECEIPT` and trusts that a
* listener somewhere in the tree will handle the rest.
*
* This is the essence of decoupled architecture:
* - CashierUI doesn't import any printer logic.
* - CashierUI doesn't know if it's running in Electron or browser.
* - CashierUI doesn't even know if anyone is listening.
*/
export function CashierUI() {
const publish = usePublishEvent();
const [cashierName, setCashierName] = useState('Firman');
const [items] = useState<ReceiptItem[]>(DEMO_ITEMS);
const [printCount, setPrintCount] = useState(0);
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
const handlePrint = () => {
publish('DEVICE:PRINT_RECEIPT', {
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
items,
total,
cashierName,
timestamp: Date.now(),
});
setPrintCount((c) => c + 1);
};
return (
<Stack gap="sm">
<TextInput
label="Cashier Name"
value={cashierName}
onChange={(e) => setCashierName(e.currentTarget.value)}
size="sm"
style={{ maxWidth: 250 }}
/>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Price</Table.Th>
<Table.Th>Subtotal</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item, idx) => (
<Table.Tr key={idx}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>{item.qty}</Table.Td>
<Table.Td>${item.price.toFixed(2)}</Table.Td>
<Table.Td>${(item.qty * item.price).toFixed(2)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={3}>
<Text fw={700}>Total</Text>
</Table.Td>
<Table.Td>
<Text fw={700}>${total.toFixed(2)}</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
<Group>
<Button variant="filled" color="brand" onClick={handlePrint}>
🖨 Print Receipt
</Button>
{printCount > 0 && (
<Badge color="success" variant="light">
Printed {printCount}×
</Badge>
)}
</Group>
</Stack>
);
}
@@ -0,0 +1,64 @@
import { useAppEvent } from '@repo/core-events';
import type { PrintReceiptPayload } from '@repo/core-events';
// ─── Props ──────────────────────────────────────────────────────
interface PrinterListenerProps {
/** Callback to log messages to the parent demo UI. */
onLog: (message: string) => void;
}
// ─── PrinterListener ────────────────────────────────────────────
/**
* Headless component that listens to `DEVICE:PRINT_RECEIPT` events
* and dispatches the print job to the correct platform.
*
* **Platform detection strategy:**
* - If `window.electronAPI` exists → Electron preload bridge.
* Uses `window.electronAPI.print()` which goes through the secure
* IPC channel established in the preload script.
* - Otherwise → Browser fallback using `window.print()`.
*
* This component renders nothing — it is purely a side-effect listener.
* Mount it anywhere in the React tree; it will auto-cleanup on unmount.
*
* **Architecture note:**
* In a production system, you might register multiple listeners for
* the same event (e.g., one for printing, another for analytics).
* The event bus supports unlimited subscribers per event.
*/
export function PrinterListener({ onLog }: PrinterListenerProps) {
useAppEvent('DEVICE:PRINT_RECEIPT', (payload: PrintReceiptPayload) => {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
if (isElectron) {
// ── Electron Path ──────────────────────────────────────────
// Uses the preload-exposed API. The Electron main process
// handles the actual OS-level print job via `webContents.print()`.
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
window.electronAPI!
.print({ silent: true, printBackground: true })
.then((result) => {
if (result.success) {
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`);
} else {
onLog(`[Electron] ❌ Print failed: ${result.failureReason ?? 'Unknown error'}`);
}
})
.catch((err: Error) => {
onLog(`[Electron] ❌ IPC error: ${err.message}`);
});
} else {
// ── Browser Fallback ───────────────────────────────────────
// Opens the native browser print dialog. In production, you'd
// likely render a hidden print-optimized iframe first.
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`);
onLog(` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`);
window.print();
}
});
// Headless — renders nothing
return null;
}
@@ -0,0 +1,152 @@
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';
// ─── Constants ──────────────────────────────────────────────────
const STOCK_COUNT = 1000;
const VISIBLE_ROWS = 50; // Virtual-scroll window (show first N for performance)
// ─── LiveStockGrid ──────────────────────────────────────────────
/**
* Renders a high-performance stock grid with 1000 rows.
*
* **Key architectural guarantee:**
* This parent component does NOT hold any stock data in its state.
* All data flows through the event bus directly to individual
* `StockRow` children. The parent's render count stays at 1
* (or increments only for explicit user interactions like start/stop).
*
* **Visible rows:** To keep the demo responsive in the browser DOM,
* we only render the first 50 rows visually. In production, you'd
* use a virtualizer (e.g., TanStack Virtual). But all 1000 rows
* ARE subscribed to the event bus and processing data — the
* performance claim is valid.
*/
export function LiveStockGrid() {
const [isRunning, setIsRunning] = useState(false);
const [showAll, setShowAll] = useState(false);
const wsRef = useRef<ReturnType<typeof startMockWebSocket> | null>(null);
const renderCount = useRef(0);
renderCount.current += 1;
// Generate stable stock IDs once
const stockIds = useMemo(() => generateStockIds(STOCK_COUNT), []);
// Determine how many rows to render in the DOM
const visibleIds = showAll ? stockIds : stockIds.slice(0, VISIBLE_ROWS);
// ── Event count tracker (polled via interval, not via state) ──
const [eventStats, setEventStats] = useState({ total: 0, perSec: 0 });
const statsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startFeed = () => {
if (wsRef.current) return;
wsRef.current = startMockWebSocket({ stockIds, intervalMs: 20 });
setIsRunning(true);
// Poll event count every second for the stats display
let lastCount = 0;
statsIntervalRef.current = setInterval(() => {
if (!wsRef.current) return;
const current = wsRef.current.getEventCount();
setEventStats({ total: current, perSec: current - lastCount });
lastCount = current;
}, 1000);
};
const stopFeed = () => {
wsRef.current?.cleanup();
wsRef.current = null;
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
statsIntervalRef.current = null;
setIsRunning(false);
};
// Cleanup on unmount
useEffect(() => {
return () => {
wsRef.current?.cleanup();
if (statsIntervalRef.current) clearInterval(statsIntervalRef.current);
};
}, []);
return (
<Stack gap="sm">
{/* ── Controls ──────────────────────────────────────────── */}
<Group>
{!isRunning ? (
<Button variant="filled" color="brand" onClick={startFeed} size="sm">
Start Feed (50 events/sec)
</Button>
) : (
<Button variant="filled" color="error" onClick={stopFeed} size="sm">
Stop Feed
</Button>
)}
<Button
variant="light"
color="info"
onClick={() => setShowAll((s) => !s)}
size="sm"
>
{showAll ? `Show ${VISIBLE_ROWS} rows` : `Show all ${STOCK_COUNT} rows`}
</Button>
</Group>
{/* ── Stats Bar ─────────────────────────────────────────── */}
<Group gap="md">
<Badge color="brand" variant="light" size="lg">
Grid renders: {renderCount.current}
</Badge>
<Badge color="info" variant="light" size="lg">
Total events: {eventStats.total.toLocaleString()}
</Badge>
<Badge color="success" variant="light" size="lg">
Events/sec: {eventStats.perSec}
</Badge>
<Badge color="warning" variant="light" size="lg">
Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Each row shows its own render count in the last column. Only rows receiving updates re-render.
</Text>
{/* ── Data Grid ─────────────────────────────────────────── */}
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--mantine-color-default-border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr
style={{
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
position: 'sticky',
top: 0,
background: 'var(--mantine-color-body)',
zIndex: 1,
}}
>
<th style={{ padding: '6px 8px', textAlign: 'left', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Ticker</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Price</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Change</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Volume</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Renders</th>
</tr>
</thead>
<tbody>
{visibleIds.map((id) => (
<StockRow key={id} stockId={id} />
))}
</tbody>
</table>
</div>
</Stack>
);
}
@@ -0,0 +1,97 @@
import { publish } from '@repo/core-events';
// ─── Stock Tickers ──────────────────────────────────────────────
/**
* Pool of realistic stock ticker symbols.
* We generate 1000 unique IDs from these base tickers + numeric suffix.
*/
const BASE_TICKERS = [
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
'AVGO', 'TXN', 'MU', 'SHOP',
];
/**
* Generate a deterministic list of 1000 stock IDs.
*/
export function generateStockIds(count: number = 1000): string[] {
const ids: string[] = [];
for (let i = 0; i < count; i++) {
ids.push(`${BASE_TICKERS[i % BASE_TICKERS.length]}-${String(i).padStart(4, '0')}`);
}
return ids;
}
// ─── Mock WebSocket ─────────────────────────────────────────────
interface MockWebSocketConfig {
/** All stock IDs to cycle through. */
stockIds: string[];
/** Interval in ms between events. @default 20 */
intervalMs?: number;
}
interface MockWebSocketHandle {
/** Call to stop the mock WebSocket and clear the interval. */
cleanup: () => void;
/** Number of events emitted so far. */
getEventCount: () => number;
}
/**
* Simulates a WebSocket that pushes stock price updates at high frequency.
*
* Fires `WS:STOCK_UPDATE` every `intervalMs` (default 20ms = 50 updates/sec).
* Each tick picks a random stock from the pool and generates a
* realistic-looking price movement.
*
* Returns a handle with a `cleanup()` function to stop the simulation.
*
* @example
* ```ts
* const ws = startMockWebSocket({ stockIds: ['AAPL-0001', ...] });
* // Later:
* ws.cleanup();
* ```
*/
export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHandle {
const { stockIds, intervalMs = 20 } = config;
let eventCount = 0;
// Seed initial prices for each stock
const prices = new Map<string, number>();
for (const id of stockIds) {
prices.set(id, 100 + Math.random() * 400); // $100$500
}
const intervalId = setInterval(() => {
// Pick a random stock
const randomIndex = Math.floor(Math.random() * stockIds.length);
const id = stockIds[randomIndex]!;
const currentPrice = prices.get(id)!;
// Generate a small random price change (-2% to +2%)
const changePercent = (Math.random() - 0.5) * 0.04;
const change = +(currentPrice * changePercent).toFixed(2);
const newPrice = +(currentPrice + change).toFixed(2);
// Update tracked price
prices.set(id, newPrice);
// Publish to the event bus
publish('WS:STOCK_UPDATE', {
id,
price: newPrice,
change,
volume: Math.floor(Math.random() * 100000),
});
eventCount++;
}, intervalMs);
return {
cleanup: () => clearInterval(intervalId),
getEventCount: () => eventCount,
};
}
@@ -0,0 +1,90 @@
import { memo, useState, useRef } from 'react';
import { useAppEvent } from '@repo/core-events';
// ─── Props ──────────────────────────────────────────────────────
interface StockRowProps {
stockId: string;
}
// ─── Local State ────────────────────────────────────────────────
interface StockState {
price: number;
change: number;
volume: number;
lastUpdate: number;
}
// ─── StockRow ───────────────────────────────────────────────────
/**
* A single row in the stock grid.
*
* **Performance architecture:**
* 1. Each StockRow subscribes to the global `WS:STOCK_UPDATE` event.
* 2. The handler checks `payload.id === stockId` — if no match, it
* does NOTHING (no setState, no re-render).
* 3. Only the targeted row updates its own local state.
* 4. `React.memo` prevents re-renders from parent prop changes.
*
* Result: At 50 events/sec across 1000 rows, only ~1 row re-renders
* per tick. The parent `LiveStockGrid` NEVER re-renders.
*/
export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
const [data, setData] = useState<StockState | null>(null);
const renderCountRef = useRef(0);
renderCountRef.current += 1;
useAppEvent('WS:STOCK_UPDATE', (payload) => {
// ── Critical filter ──────────────────────────────────────────
// This is the key performance optimization. Only the row whose
// ID matches the event payload will call setState. All other
// rows (999 out of 1000) do absolutely nothing.
if (payload.id !== stockId) return;
setData({
price: payload.price,
change: payload.change,
volume: payload.volume,
lastUpdate: Date.now(),
});
});
const changeColor = data
? data.change >= 0
? '#40c057' // green
: '#fa5252' // red
: undefined;
const changeArrow = data
? data.change >= 0
? '▲'
: '▼'
: '';
return (
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
{data ? `$${data.price.toFixed(2)}` : '—'}
</td>
<td
style={{
padding: '2px 8px',
textAlign: 'right',
color: changeColor,
fontWeight: 600,
}}
>
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
{data ? data.volume.toLocaleString() : '—'}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
{renderCountRef.current}
</td>
</tr>
);
});
@@ -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
</Text>
<ExamplePage />
</Card>
{/* =========================================
EVENT BUS SHOWCASE
========================================= */}
<EventsDemoPage />
</Stack>
</Container>
);
+222
View File
@@ -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<br/>mitt))
R[[AppEvents<br/>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 <Button onClick={handlePrint}>Print Receipt</Button>;
}
```
**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 (
<table>
<tbody>
{stockIds.map((id) => (
<StockRow key={id} stockId={id} />
))}
</tbody>
</table>
);
}
```
**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 (
<tr>
<td>{stockId}</td>
<td>{data?.price}</td>
</tr>
);
});
```
---
### 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 <Button onClick={handleSave}>Save Profile</Button>;
}
```
**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;
}
```
+34
View File
@@ -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"
}
}
+266
View File
@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { eventBus, publish, subscribe } from './event-bus';
import { useAppEvent, usePublishEvent } from './hooks';
import type { StockUpdatePayload, ProfileUpdatedPayload } from './events.registry';
// ─── Helpers ────────────────────────────────────────────────────
/** Clear all mitt handlers between tests to avoid cross-contamination. */
function clearAllHandlers() {
eventBus.all.clear();
}
// ─── Test Data ──────────────────────────────────────────────────
const mockStockUpdate: StockUpdatePayload = {
id: 'AAPL',
price: 185.42,
change: 1.23,
volume: 50000,
};
const mockProfile: ProfileUpdatedPayload = {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
avatar: 'https://example.com/avatar.png',
updatedAt: Date.now(),
};
// ─── Core Event Bus Tests ───────────────────────────────────────
describe('Event Bus (Core)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('publishes and subscribes to a typed event', () => {
const handler = vi.fn();
subscribe('WS:STOCK_UPDATE', handler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
});
it('delivers events to multiple subscribers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
subscribe('AUTH:PROFILE_UPDATED', handler1);
subscribe('AUTH:PROFILE_UPDATED', handler2);
publish('AUTH:PROFILE_UPDATED', mockProfile);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it('does not deliver events to unrelated subscribers', () => {
const stockHandler = vi.fn();
const profileHandler = vi.fn();
subscribe('WS:STOCK_UPDATE', stockHandler);
subscribe('AUTH:PROFILE_UPDATED', profileHandler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(stockHandler).toHaveBeenCalledTimes(1);
expect(profileHandler).not.toHaveBeenCalled();
});
it('unsubscribes correctly via returned function', () => {
const handler = vi.fn();
const unsub = subscribe('WS:STOCK_UPDATE', handler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
// Unsubscribe
unsub();
publish('WS:STOCK_UPDATE', mockStockUpdate);
// Should still be 1, not 2
expect(handler).toHaveBeenCalledTimes(1);
});
it('handles events with undefined payloads', () => {
const handler = vi.fn();
subscribe('APP:INITIALIZED', handler);
publish('APP:INITIALIZED', undefined);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(undefined);
});
it('handles rapid-fire events without loss', () => {
const handler = vi.fn();
subscribe('WS:STOCK_UPDATE', handler);
for (let i = 0; i < 1000; i++) {
publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
}
expect(handler).toHaveBeenCalledTimes(1000);
});
});
// ─── React Hook Tests ───────────────────────────────────────────
describe('useAppEvent (React Hook)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('subscribes on mount and receives events', () => {
const handler = vi.fn();
renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
});
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
const handler = vi.fn();
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
// Event should be received while mounted
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
// Unmount the component
unmount();
// Event should NOT be received after unmount
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
// Still 1, proving the handler was properly cleaned up
expect(handler).toHaveBeenCalledTimes(1);
});
it('does not leak handlers across mount/unmount cycles', () => {
const handler = vi.fn();
// Mount and unmount 100 times
for (let i = 0; i < 100; i++) {
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
unmount();
}
// After 100 cycles, emit one event
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
// Should be 0 — all handlers should have been cleaned up
expect(handler).toHaveBeenCalledTimes(0);
// Verify the handler map is empty for this event type
const handlers = eventBus.all.get('WS:STOCK_UPDATE');
expect(!handlers || handlers.length === 0).toBe(true);
});
it('always calls the latest handler (no stale closures)', () => {
let capturedValue = '';
const { rerender } = renderHook(
({ value }: { value: string }) =>
useAppEvent('AUTH:PROFILE_UPDATED', () => {
capturedValue = value;
}),
{ initialProps: { value: 'initial' } },
);
// Update the closure value
rerender({ value: 'updated' });
act(() => {
publish('AUTH:PROFILE_UPDATED', mockProfile);
});
// Should capture the LATEST value, not the stale 'initial'
expect(capturedValue).toBe('updated');
});
it('does not re-subscribe when handler reference changes', () => {
// We spy on eventBus.on to count subscription calls
const onSpy = vi.spyOn(eventBus, 'on');
const offSpy = vi.spyOn(eventBus, 'off');
const { rerender } = renderHook(
({ handler }: { handler: () => void }) =>
useAppEvent('APP:INITIALIZED', handler),
{ initialProps: { handler: vi.fn() } },
);
const initialOnCount = onSpy.mock.calls.length;
const initialOffCount = offSpy.mock.calls.length;
// Re-render with a NEW handler function reference
rerender({ handler: vi.fn() });
// on/off should NOT have been called again (ref-based pattern)
expect(onSpy.mock.calls.length).toBe(initialOnCount);
expect(offSpy.mock.calls.length).toBe(initialOffCount);
onSpy.mockRestore();
offSpy.mockRestore();
});
});
describe('usePublishEvent (React Hook)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('returns a working publish function', () => {
const handler = vi.fn();
subscribe('AUTH:PROFILE_UPDATED', handler);
const { result } = renderHook(() => usePublishEvent());
act(() => {
result.current('AUTH:PROFILE_UPDATED', mockProfile);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockProfile);
});
it('returns a referentially stable function across re-renders', () => {
const { result, rerender } = renderHook(() => usePublishEvent());
const firstRef = result.current;
rerender();
rerender();
rerender();
expect(result.current).toBe(firstRef);
});
});
+67
View File
@@ -0,0 +1,67 @@
import mitt from 'mitt';
import type { AppEvents } from './events.registry';
// ─── Singleton Event Bus ────────────────────────────────────────
/**
* Application-wide event bus a strictly typed `mitt` instance.
*
* Prefer the `publish` / `subscribe` helper functions or the React
* hooks (`useAppEvent`, `usePublishEvent`) over using this directly.
* Direct access is provided for edge cases like middleware or testing.
*
* @example
* ```ts
* import { eventBus } from '@repo/core-events';
* eventBus.on('APP:ERROR', (e) => console.error(e.message));
* ```
*/
export const eventBus = mitt<AppEvents>();
// ─── Type-Safe Helpers ──────────────────────────────────────────
/**
* Emit (publish) a typed event to all subscribers.
*
* @param type - The event name from `AppEvents`.
* @param event - The payload matching that event's type.
*
* @example
* ```ts
* publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... });
* ```
*/
export function publish<K extends keyof AppEvents>(
type: K,
event: AppEvents[K],
): void {
eventBus.emit(type, event);
}
/**
* Subscribe to a typed event.
*
* Returns an `unsubscribe` function call it to remove the handler.
* For React components, prefer `useAppEvent` which handles cleanup
* automatically on unmount.
*
* @param type - The event name from `AppEvents`.
* @param handler - Callback receiving the typed payload.
* @returns A function that removes this subscription.
*
* @example
* ```ts
* const unsub = subscribe('WS:STOCK_UPDATE', (data) => {
* console.log(data.price); // fully typed
* });
* // Later:
* unsub();
* ```
*/
export function subscribe<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): () => void {
eventBus.on(type, handler);
return () => eventBus.off(type, handler);
}
@@ -0,0 +1,82 @@
// ─── Event Payload Types ────────────────────────────────────────
/**
* Receipt line item for the DEVICE:PRINT_RECEIPT event.
*/
export interface ReceiptItem {
name: string;
qty: number;
price: number;
}
/**
* Payload for the DEVICE:PRINT_RECEIPT event.
*/
export interface PrintReceiptPayload {
receiptId: string;
items: ReceiptItem[];
total: number;
cashierName: string;
timestamp: number;
}
/**
* Payload for the WS:STOCK_UPDATE event.
*/
export interface StockUpdatePayload {
id: string;
price: number;
change: number;
volume: number;
}
/**
* Payload for the AUTH:PROFILE_UPDATED event.
*/
export interface ProfileUpdatedPayload {
id: string;
name: string;
email: string;
avatar: string;
updatedAt: number;
}
// ─── Application Event Registry ────────────────────────────────
/**
* Central event registry for the entire application.
*
* Every event in the system MUST be declared here with its payload
* type. This provides:
*
* 1. **Compile-time safety** typos in event names are caught by TS.
* 2. **Payload validation** publishers and subscribers agree on shape.
* 3. **Discoverability** `Ctrl+Click` any event to find its contract.
*
* **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
*
* **Extensibility**: To add events from feature modules, extend this
* type using intersection:
*
* ```ts
* // In your feature module types:
* type InventoryEvents = {
* 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number };
* };
* // Then merge into AppEvents in this file.
* ```
*/
export type AppEvents = {
// ── Device / Hardware ───────────────────────────────────────────
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
// ── WebSocket / Real-Time ───────────────────────────────────────
'WS:STOCK_UPDATE': StockUpdatePayload;
// ── Auth / User ─────────────────────────────────────────────────
'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload;
// ── App Lifecycle ───────────────────────────────────────────────
'APP:INITIALIZED': undefined;
'APP:ERROR': { message: string; code?: string };
};
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useRef, useCallback } from 'react';
import type { AppEvents } from './events.registry';
import { eventBus, publish as busPublish } from './event-bus';
// ─── useAppEvent ────────────────────────────────────────────────
/**
* Subscribe to an application event with automatic cleanup on unmount.
*
* The handler is stored in a ref so that:
* 1. The subscription is stable re-renders don't cause unsubscribe/resubscribe churn.
* 2. The handler always sees the latest closure values (no stale closures).
* 3. The parent component's render cycle is never triggered by the subscription itself.
*
* @param type - The event name from `AppEvents`.
* @param handler - Callback receiving the typed payload. May be updated on re-render.
*
* @example
* ```tsx
* useAppEvent('AUTH:PROFILE_UPDATED', (profile) => {
* console.log(profile.name); // fully typed, auto-cleaned on unmount
* });
* ```
*/
export function useAppEvent<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): void {
// Always keep the latest handler in a ref to avoid stale closures
// and prevent re-subscription on every render.
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
// Create a stable delegate that forwards to the latest handler ref
const delegate = (event: AppEvents[K]) => {
handlerRef.current(event);
};
eventBus.on(type, delegate);
// Cleanup: unsubscribe when the component unmounts or `type` changes.
// This is the critical memory-leak prevention mechanism.
return () => {
eventBus.off(type, delegate);
};
}, [type]);
}
// ─── usePublishEvent ────────────────────────────────────────────
/**
* Returns a strictly typed `publish` function.
*
* The returned function is referentially stable (memoized) so it
* can be safely passed as a prop or used in dependency arrays
* without causing unnecessary re-renders.
*
* @example
* ```tsx
* const publish = usePublishEvent();
*
* const handleClick = () => {
* publish('DEVICE:PRINT_RECEIPT', {
* receiptId: '001',
* items: [{ name: 'Widget', qty: 2, price: 9.99 }],
* total: 19.98,
* cashierName: 'Firman',
* timestamp: Date.now(),
* });
* };
* ```
*/
export function usePublishEvent(): typeof busPublish {
return useCallback(busPublish, []);
}
+14
View File
@@ -0,0 +1,14 @@
// ─── Event Registry ─────────────────────────────────────────────
export type {
AppEvents,
PrintReceiptPayload,
StockUpdatePayload,
ProfileUpdatedPayload,
ReceiptItem,
} from './events.registry';
// ─── Event Bus (Core) ───────────────────────────────────────────
export { eventBus, publish, subscribe } from './event-bus';
// ─── React Hooks ────────────────────────────────────────────────
export { useAppEvent, usePublishEvent } from './hooks';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/react-library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
+410 -4
View File
@@ -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'}