feat: introduce core-events package with event bus, hooks, and a web showcase demo
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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, []);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Generated
+410
-4
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user