diff --git a/README.md b/README.md index e86f1fc..4e945de 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/web/package.json b/apps/web/package.json index 945b931..fab2c64 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@repo/core-api": "workspace:*", + "@repo/core-events": "workspace:*", "@repo/core-i18n": "workspace:*", "@repo/core-storage": "workspace:*", "@repo/ui": "workspace:*", diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx new file mode 100644 index 0000000..33b10eb --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx @@ -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 ( + + + setName(e.currentTarget.value)} size="sm" /> + setEmail(e.currentTarget.value)} size="sm" /> + + + setAvatar(e.currentTarget.value)} size="sm" /> + + + + {saveCount > 0 && ( + + Synced {saveCount}ร— + + )} + + + ); +} diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx new file mode 100644 index 0000000..8d89489 --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx @@ -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; +} diff --git a/apps/web/src/apps/showcase/events-demo/index.tsx b/apps/web/src/apps/showcase/events-demo/index.tsx new file mode 100644 index 0000000..0581990 --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/index.tsx @@ -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([]); + + // โ”€โ”€ Showcase 3: Storage sync status feedback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [syncLog, setSyncLog] = useState([]); + + // โ”€โ”€ 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 ( + +
+ ๐Ÿ”Œ Event Bus Showcase + + Three real-world demos of @repo/core-events โ€” zero coupling, strict typing, high performance. + + + Parent render count: {renderCount.current} + +
+ + {/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + SHOWCASE 1: Cross-Platform Printer Abstraction + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} + + + ๐Ÿ–จ๏ธ Showcase 1: Cross-Platform Printer Abstraction + + + The CashierUI publishes a DEVICE:PRINT_RECEIPT event. + A headless PrinterListener decides whether to use Electron IPC or browser print. + + + {/* Headless listener โ€” renders nothing visible */} + setPrinterLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])} + /> + + + + {printerLog.length > 0 && ( + <> + + + ๐Ÿ“‹ Printer Log: + +
+ {printerLog.map((line, i) => ( +
{line}
+ ))} +
+ + )} +
+ + {/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + SHOWCASE 2: Extreme Performance โ€” Live Stock Grid + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} + + + ๐Ÿ“ˆ Showcase 2: High-Frequency Real-Time Data (50 updates/sec) + + + A mock WebSocket fires WS:STOCK_UPDATE every 20ms. + Each StockRow subscribes to the global event but only updates when{' '} + payload.id === row.id. The parent grid never re-renders. + + + + + + {/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + SHOWCASE 3: Auth/Profile โ†’ IndexedDB Sync + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} + + + ๐Ÿ’พ Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code> + + + ProfileSettingsUI publishes AUTH:PROFILE_UPDATED. + A headless StorageSyncListener persists it to IndexedDB via secureIndexedDB. + + + {/* Headless listener โ€” renders nothing visible */} + setSyncLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`])} + /> + + + + {syncLog.length > 0 && ( + <> + + + ๐Ÿ“‹ Storage Sync Log: + +
+ {syncLog.map((line, i) => ( +
{line}
+ ))} +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx b/apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx new file mode 100644 index 0000000..475befb --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx @@ -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(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 ( + + setCashierName(e.currentTarget.value)} + size="sm" + style={{ maxWidth: 250 }} + /> + + + + + Item + Qty + Price + Subtotal + + + + {items.map((item, idx) => ( + + {item.name} + {item.qty} + ${item.price.toFixed(2)} + ${(item.qty * item.price).toFixed(2)} + + ))} + + + + + Total + + + ${total.toFixed(2)} + + + +
+ + + + {printCount > 0 && ( + + Printed {printCount}ร— + + )} + +
+ ); +} diff --git a/apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx b/apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx new file mode 100644 index 0000000..406dc4c --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx @@ -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; +} diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx new file mode 100644 index 0000000..e46395f --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx @@ -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 | 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 | 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 ( + + {/* โ”€โ”€ Controls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + {!isRunning ? ( + + ) : ( + + )} + + + + {/* โ”€โ”€ Stats Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + + Grid renders: {renderCount.current} + + + Total events: {eventStats.total.toLocaleString()} + + + Events/sec: {eventStats.perSec} + + + Subscribed rows: {STOCK_COUNT} | Visible: {visibleIds.length} + + + + + Each row shows its own render count in the last column. Only rows receiving updates re-render. + + + {/* โ”€โ”€ Data Grid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} +
+ + + + + + + + + + + + {visibleIds.map((id) => ( + + ))} + +
TickerPriceChangeVolumeRenders
+
+
+ ); +} diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts b/apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts new file mode 100644 index 0000000..dcf19e8 --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts @@ -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(); + 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, + }; +} diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx new file mode 100644 index 0000000..f949ff8 --- /dev/null +++ b/apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx @@ -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(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 ( + + {stockId} + + {data ? `$${data.price.toFixed(2)}` : 'โ€”'} + + + {data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : 'โ€”'} + + + {data ? data.volume.toLocaleString() : 'โ€”'} + + + {renderCountRef.current} + + + ); +}); diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index fdcd726..5cf52bf 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -21,6 +21,7 @@ import { } from '@repo/ui/components'; import PrinterList from './printer-list'; import ExamplePage from './example/example.page'; +import EventsDemoPage from './events-demo'; interface ShowcaseViewProps { colorScheme: ColorSchemeType; @@ -219,6 +220,11 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set + + {/* ========================================= + EVENT BUS SHOWCASE + ========================================= */} + ); diff --git a/packages/core-events/README.md b/packages/core-events/README.md new file mode 100644 index 0000000..3b3c46a --- /dev/null +++ b/packages/core-events/README.md @@ -0,0 +1,222 @@ +# @repo/core-events + +[โ† Back to Root](../../README.md) + +## Overview + +`@repo/core-events` is the **decoupled Nervous System** of the ERP. It provides a highly performant, strictly typed Event Bus powered by `mitt` and React hooks. + +By routing communication through a centralized event bus, we achieve: +- **Zero Coupling**: Publishers and subscribers don't need to import or know about each other. +- **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets) and update their own local state *without* triggering massive React tree re-renders. +- **Memory Safety**: The provided `useAppEvent` hook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures. +- **Strict Contracts**: The `AppEvents` registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract. + +--- + +## Architecture + +```mermaid +graph TD + subgraph Publishers + A[Cashier UI] + B[Profile Settings] + C[WebSocket Client] + end + + subgraph Core + E((Event Bus
mitt)) + R[[AppEvents
Registry]] -.-> E + end + + subgraph Subscribers + X[Electron IPC Bridge] + Y[IndexedDB Sync] + Z[Stock Grid Row] + end + + A -- "DEVICE:PRINT_RECEIPT" --> E + B -- "AUTH:PROFILE_UPDATED" --> E + C -- "WS:STOCK_UPDATE" --> E + + E -.-> X + E -.-> Y + E -.-> Z + + style E fill:#4263eb,color:#fff,stroke:#fff + style R fill:#2b8a3e,color:#fff,stroke:#fff +``` + +--- + +## Defining Events + +Every event in the system MUST be registered in `src/events.registry.ts`. This provides a single source of truth and full autocomplete across the codebase. + +To add a new event, simply extend the `AppEvents` type: + +```typescript +// packages/core-events/src/events.registry.ts + +export interface CheckoutPayload { + orderId: string; + total: number; +} + +export type AppEvents = { + // Existing events... + 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload; + + // Your new event: + 'STORE:CHECKOUT_COMPLETED': CheckoutPayload; +}; +``` + +--- + +## Usage Examples + +Here are three real-world architectural patterns powered by the Event Bus. + +### Example 1: Hardware Abstraction (Cross-Platform) + +**Problem**: The web app needs to print receipts. If running in a browser, it should use `window.print()`. If running in the Electron wrapper, it must use the secure IPC bridge (`window.electronAPI.print()`). We don't want the UI components cluttered with platform-detection logic. + +**Solution**: The UI publishes a blind event. A headless listener handles the platform routing. + +**Publisher (Cashier UI)**: +```tsx +import { usePublishEvent } from '@repo/core-events'; + +export function CashierUI() { + const publish = usePublishEvent(); + + const handlePrint = () => { + // Fire and forget. Zero knowledge of how printing actually happens. + publish('DEVICE:PRINT_RECEIPT', { + receiptId: 'RCP-123', + items: [...], + total: 45.00, + cashierName: 'Firman' + }); + }; + + return ; +} +``` + +**Subscriber (Headless Listener)**: +```tsx +import { useAppEvent } from '@repo/core-events'; + +export function PrinterListener() { + useAppEvent('DEVICE:PRINT_RECEIPT', (payload) => { + const isElectron = typeof window !== 'undefined' && !!window.electronAPI; + + if (isElectron) { + // Route via secure Electron IPC bridge + window.electronAPI.print({ silent: true }); + } else { + // Fallback to standard browser print dialog + window.print(); + } + }); + + return null; // Renders nothing +} +``` + +--- + +### Example 2: Extreme Performance (High-Frequency Data) + +**Problem**: A massive data grid (1,000+ rows) receives 50 WebSocket updates per second. If the parent grid holds the state and passes it down via props, React will attempt to re-render all 1,000 rows 50 times a second, crushing the browser. + +**Solution**: The parent grid renders empty rows. Each row subscribes to the event bus and filters updates so it only re-renders when its specific data changes. + +**Parent Grid (Never re-renders)**: +```tsx +export function LiveStockGrid() { + // Generates 1000 IDs once. No stock data is stored here! + const stockIds = generateStockIds(1000); + + return ( + + + {stockIds.map((id) => ( + + ))} + +
+ ); +} +``` + +**Child Row (Targeted Updates)**: +```tsx +import { memo, useState } from 'react'; +import { useAppEvent } from '@repo/core-events'; + +export const StockRow = memo(function StockRow({ stockId }) { + const [data, setData] = useState(null); + + useAppEvent('WS:STOCK_UPDATE', (payload) => { + // CRITICAL: Filter out events for other rows. + // 999 out of 1000 rows will exit here instantly without causing a re-render. + if (payload.id !== stockId) return; + + // Only the targeted row updates its local state + setData(payload); + }); + + return ( + + {stockId} + {data?.price} + + ); +}); +``` + +--- + +### Example 3: Background Sync (Auth to IndexedDB) + +**Problem**: When a user updates their profile, we need to persist it to the secure local IndexedDB. We don't want to tightly couple our UI forms to the `@repo/core-storage` package. + +**Solution**: The UI form announces the profile update. A dedicated storage listener persists it in the background. + +**Publisher (Profile UI)**: +```tsx +import { usePublishEvent } from '@repo/core-events'; + +export function ProfileSettingsUI() { + const publish = usePublishEvent(); + + const handleSave = () => { + publish('AUTH:PROFILE_UPDATED', { + id: 'user-1', + name: 'Firman', + email: 'firman@eigen.co.id', + }); + }; + + return ; +} +``` + +**Subscriber (Storage Sync Listener)**: +```tsx +import { useAppEvent } from '@repo/core-events'; +import { secureIndexedDB } from '@repo/core-storage'; + +export function StorageSyncListener() { + useAppEvent('AUTH:PROFILE_UPDATED', (payload) => { + // Automatically encrypted at rest because 'user_profile' + // is defined in ENCRYPTED_KEYS in @repo/core-storage + secureIndexedDB.setItem('user_profile', payload).catch(console.error); + }); + + return null; +} +``` diff --git a/packages/core-events/package.json b/packages/core-events/package.json new file mode 100644 index 0000000..aad0b36 --- /dev/null +++ b/packages/core-events/package.json @@ -0,0 +1,34 @@ +{ + "name": "@repo/core-events", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "license": "MIT", + "scripts": { + "lint": "eslint \"**/*.ts\" \"**/*.tsx\"", + "test": "vitest run", + "test:watch": "vitest --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "mitt": "^3.0.1" + }, + "peerDependencies": { + "react": ">=18.0.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "eslint": "^8.57.1", + "jsdom": "^26.1.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "typescript": "5.5.4", + "vitest": "^4.0.17" + } +} diff --git a/packages/core-events/src/event-bus.test.ts b/packages/core-events/src/event-bus.test.ts new file mode 100644 index 0000000..0e47d92 --- /dev/null +++ b/packages/core-events/src/event-bus.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { eventBus, publish, subscribe } from './event-bus'; +import { useAppEvent, usePublishEvent } from './hooks'; +import type { StockUpdatePayload, ProfileUpdatedPayload } from './events.registry'; + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Clear all mitt handlers between tests to avoid cross-contamination. */ +function clearAllHandlers() { + eventBus.all.clear(); +} + +// โ”€โ”€โ”€ Test Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const mockStockUpdate: StockUpdatePayload = { + id: 'AAPL', + price: 185.42, + change: 1.23, + volume: 50000, +}; + +const mockProfile: ProfileUpdatedPayload = { + id: 'user-1', + name: 'Firman', + email: 'firman@eigen.co.id', + avatar: 'https://example.com/avatar.png', + updatedAt: Date.now(), +}; + +// โ”€โ”€โ”€ Core Event Bus Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('Event Bus (Core)', () => { + beforeEach(() => { + clearAllHandlers(); + }); + + afterEach(() => { + clearAllHandlers(); + }); + + it('publishes and subscribes to a typed event', () => { + const handler = vi.fn(); + + subscribe('WS:STOCK_UPDATE', handler); + publish('WS:STOCK_UPDATE', mockStockUpdate); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(mockStockUpdate); + }); + + it('delivers events to multiple subscribers', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + subscribe('AUTH:PROFILE_UPDATED', handler1); + subscribe('AUTH:PROFILE_UPDATED', handler2); + publish('AUTH:PROFILE_UPDATED', mockProfile); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + + it('does not deliver events to unrelated subscribers', () => { + const stockHandler = vi.fn(); + const profileHandler = vi.fn(); + + subscribe('WS:STOCK_UPDATE', stockHandler); + subscribe('AUTH:PROFILE_UPDATED', profileHandler); + + publish('WS:STOCK_UPDATE', mockStockUpdate); + + expect(stockHandler).toHaveBeenCalledTimes(1); + expect(profileHandler).not.toHaveBeenCalled(); + }); + + it('unsubscribes correctly via returned function', () => { + const handler = vi.fn(); + + const unsub = subscribe('WS:STOCK_UPDATE', handler); + + publish('WS:STOCK_UPDATE', mockStockUpdate); + expect(handler).toHaveBeenCalledTimes(1); + + // Unsubscribe + unsub(); + + publish('WS:STOCK_UPDATE', mockStockUpdate); + // Should still be 1, not 2 + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('handles events with undefined payloads', () => { + const handler = vi.fn(); + + subscribe('APP:INITIALIZED', handler); + publish('APP:INITIALIZED', undefined); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(undefined); + }); + + it('handles rapid-fire events without loss', () => { + const handler = vi.fn(); + subscribe('WS:STOCK_UPDATE', handler); + + for (let i = 0; i < 1000; i++) { + publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` }); + } + + expect(handler).toHaveBeenCalledTimes(1000); + }); +}); + +// โ”€โ”€โ”€ React Hook Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('useAppEvent (React Hook)', () => { + beforeEach(() => { + clearAllHandlers(); + }); + + afterEach(() => { + clearAllHandlers(); + }); + + it('subscribes on mount and receives events', () => { + const handler = vi.fn(); + + renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler)); + + act(() => { + publish('WS:STOCK_UPDATE', mockStockUpdate); + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(mockStockUpdate); + }); + + it('unsubscribes on unmount โ€” MEMORY LEAK PREVENTION', () => { + const handler = vi.fn(); + + const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler)); + + // Event should be received while mounted + act(() => { + publish('WS:STOCK_UPDATE', mockStockUpdate); + }); + expect(handler).toHaveBeenCalledTimes(1); + + // Unmount the component + unmount(); + + // Event should NOT be received after unmount + act(() => { + publish('WS:STOCK_UPDATE', mockStockUpdate); + }); + + // Still 1, proving the handler was properly cleaned up + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does not leak handlers across mount/unmount cycles', () => { + const handler = vi.fn(); + + // Mount and unmount 100 times + for (let i = 0; i < 100; i++) { + const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler)); + unmount(); + } + + // After 100 cycles, emit one event + act(() => { + publish('WS:STOCK_UPDATE', mockStockUpdate); + }); + + // Should be 0 โ€” all handlers should have been cleaned up + expect(handler).toHaveBeenCalledTimes(0); + + // Verify the handler map is empty for this event type + const handlers = eventBus.all.get('WS:STOCK_UPDATE'); + expect(!handlers || handlers.length === 0).toBe(true); + }); + + it('always calls the latest handler (no stale closures)', () => { + let capturedValue = ''; + + const { rerender } = renderHook( + ({ value }: { value: string }) => + useAppEvent('AUTH:PROFILE_UPDATED', () => { + capturedValue = value; + }), + { initialProps: { value: 'initial' } }, + ); + + // Update the closure value + rerender({ value: 'updated' }); + + act(() => { + publish('AUTH:PROFILE_UPDATED', mockProfile); + }); + + // Should capture the LATEST value, not the stale 'initial' + expect(capturedValue).toBe('updated'); + }); + + it('does not re-subscribe when handler reference changes', () => { + // We spy on eventBus.on to count subscription calls + const onSpy = vi.spyOn(eventBus, 'on'); + const offSpy = vi.spyOn(eventBus, 'off'); + + const { rerender } = renderHook( + ({ handler }: { handler: () => void }) => + useAppEvent('APP:INITIALIZED', handler), + { initialProps: { handler: vi.fn() } }, + ); + + const initialOnCount = onSpy.mock.calls.length; + const initialOffCount = offSpy.mock.calls.length; + + // Re-render with a NEW handler function reference + rerender({ handler: vi.fn() }); + + // on/off should NOT have been called again (ref-based pattern) + expect(onSpy.mock.calls.length).toBe(initialOnCount); + expect(offSpy.mock.calls.length).toBe(initialOffCount); + + onSpy.mockRestore(); + offSpy.mockRestore(); + }); +}); + +describe('usePublishEvent (React Hook)', () => { + beforeEach(() => { + clearAllHandlers(); + }); + + afterEach(() => { + clearAllHandlers(); + }); + + it('returns a working publish function', () => { + const handler = vi.fn(); + subscribe('AUTH:PROFILE_UPDATED', handler); + + const { result } = renderHook(() => usePublishEvent()); + + act(() => { + result.current('AUTH:PROFILE_UPDATED', mockProfile); + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(mockProfile); + }); + + it('returns a referentially stable function across re-renders', () => { + const { result, rerender } = renderHook(() => usePublishEvent()); + + const firstRef = result.current; + + rerender(); + rerender(); + rerender(); + + expect(result.current).toBe(firstRef); + }); +}); diff --git a/packages/core-events/src/event-bus.ts b/packages/core-events/src/event-bus.ts new file mode 100644 index 0000000..010c097 --- /dev/null +++ b/packages/core-events/src/event-bus.ts @@ -0,0 +1,67 @@ +import mitt from 'mitt'; +import type { AppEvents } from './events.registry'; + +// โ”€โ”€โ”€ Singleton Event Bus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Application-wide event bus โ€” a strictly typed `mitt` instance. + * + * Prefer the `publish` / `subscribe` helper functions or the React + * hooks (`useAppEvent`, `usePublishEvent`) over using this directly. + * Direct access is provided for edge cases like middleware or testing. + * + * @example + * ```ts + * import { eventBus } from '@repo/core-events'; + * eventBus.on('APP:ERROR', (e) => console.error(e.message)); + * ``` + */ +export const eventBus = mitt(); + +// โ”€โ”€โ”€ Type-Safe Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Emit (publish) a typed event to all subscribers. + * + * @param type - The event name from `AppEvents`. + * @param event - The payload matching that event's type. + * + * @example + * ```ts + * publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... }); + * ``` + */ +export function publish( + type: K, + event: AppEvents[K], +): void { + eventBus.emit(type, event); +} + +/** + * Subscribe to a typed event. + * + * Returns an `unsubscribe` function โ€” call it to remove the handler. + * For React components, prefer `useAppEvent` which handles cleanup + * automatically on unmount. + * + * @param type - The event name from `AppEvents`. + * @param handler - Callback receiving the typed payload. + * @returns A function that removes this subscription. + * + * @example + * ```ts + * const unsub = subscribe('WS:STOCK_UPDATE', (data) => { + * console.log(data.price); // fully typed + * }); + * // Later: + * unsub(); + * ``` + */ +export function subscribe( + type: K, + handler: (event: AppEvents[K]) => void, +): () => void { + eventBus.on(type, handler); + return () => eventBus.off(type, handler); +} diff --git a/packages/core-events/src/events.registry.ts b/packages/core-events/src/events.registry.ts new file mode 100644 index 0000000..229d348 --- /dev/null +++ b/packages/core-events/src/events.registry.ts @@ -0,0 +1,82 @@ +// โ”€โ”€โ”€ Event Payload Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Receipt line item for the DEVICE:PRINT_RECEIPT event. + */ +export interface ReceiptItem { + name: string; + qty: number; + price: number; +} + +/** + * Payload for the DEVICE:PRINT_RECEIPT event. + */ +export interface PrintReceiptPayload { + receiptId: string; + items: ReceiptItem[]; + total: number; + cashierName: string; + timestamp: number; +} + +/** + * Payload for the WS:STOCK_UPDATE event. + */ +export interface StockUpdatePayload { + id: string; + price: number; + change: number; + volume: number; +} + +/** + * Payload for the AUTH:PROFILE_UPDATED event. + */ +export interface ProfileUpdatedPayload { + id: string; + name: string; + email: string; + avatar: string; + updatedAt: number; +} + +// โ”€โ”€โ”€ Application Event Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Central event registry for the entire application. + * + * Every event in the system MUST be declared here with its payload + * type. This provides: + * + * 1. **Compile-time safety** โ€” typos in event names are caught by TS. + * 2. **Payload validation** โ€” publishers and subscribers agree on shape. + * 3. **Discoverability** โ€” `Ctrl+Click` any event to find its contract. + * + * **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`. + * + * **Extensibility**: To add events from feature modules, extend this + * type using intersection: + * + * ```ts + * // In your feature module types: + * type InventoryEvents = { + * 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number }; + * }; + * // Then merge into AppEvents in this file. + * ``` + */ +export type AppEvents = { + // โ”€โ”€ Device / Hardware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload; + + // โ”€โ”€ WebSocket / Real-Time โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 'WS:STOCK_UPDATE': StockUpdatePayload; + + // โ”€โ”€ Auth / User โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload; + + // โ”€โ”€ App Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 'APP:INITIALIZED': undefined; + 'APP:ERROR': { message: string; code?: string }; +}; diff --git a/packages/core-events/src/hooks.ts b/packages/core-events/src/hooks.ts new file mode 100644 index 0000000..a3e5fc3 --- /dev/null +++ b/packages/core-events/src/hooks.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useCallback } from 'react'; +import type { AppEvents } from './events.registry'; +import { eventBus, publish as busPublish } from './event-bus'; + +// โ”€โ”€โ”€ useAppEvent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Subscribe to an application event with automatic cleanup on unmount. + * + * The handler is stored in a ref so that: + * 1. The subscription is stable โ€” re-renders don't cause unsubscribe/resubscribe churn. + * 2. The handler always sees the latest closure values (no stale closures). + * 3. The parent component's render cycle is never triggered by the subscription itself. + * + * @param type - The event name from `AppEvents`. + * @param handler - Callback receiving the typed payload. May be updated on re-render. + * + * @example + * ```tsx + * useAppEvent('AUTH:PROFILE_UPDATED', (profile) => { + * console.log(profile.name); // fully typed, auto-cleaned on unmount + * }); + * ``` + */ +export function useAppEvent( + type: K, + handler: (event: AppEvents[K]) => void, +): void { + // Always keep the latest handler in a ref to avoid stale closures + // and prevent re-subscription on every render. + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + // Create a stable delegate that forwards to the latest handler ref + const delegate = (event: AppEvents[K]) => { + handlerRef.current(event); + }; + + eventBus.on(type, delegate); + + // Cleanup: unsubscribe when the component unmounts or `type` changes. + // This is the critical memory-leak prevention mechanism. + return () => { + eventBus.off(type, delegate); + }; + }, [type]); +} + +// โ”€โ”€โ”€ usePublishEvent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Returns a strictly typed `publish` function. + * + * The returned function is referentially stable (memoized) so it + * can be safely passed as a prop or used in dependency arrays + * without causing unnecessary re-renders. + * + * @example + * ```tsx + * const publish = usePublishEvent(); + * + * const handleClick = () => { + * publish('DEVICE:PRINT_RECEIPT', { + * receiptId: '001', + * items: [{ name: 'Widget', qty: 2, price: 9.99 }], + * total: 19.98, + * cashierName: 'Firman', + * timestamp: Date.now(), + * }); + * }; + * ``` + */ +export function usePublishEvent(): typeof busPublish { + return useCallback(busPublish, []); +} diff --git a/packages/core-events/src/index.ts b/packages/core-events/src/index.ts new file mode 100644 index 0000000..605928a --- /dev/null +++ b/packages/core-events/src/index.ts @@ -0,0 +1,14 @@ +// โ”€โ”€โ”€ Event Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export type { + AppEvents, + PrintReceiptPayload, + StockUpdatePayload, + ProfileUpdatedPayload, + ReceiptItem, +} from './events.registry'; + +// โ”€โ”€โ”€ Event Bus (Core) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { eventBus, publish, subscribe } from './event-bus'; + +// โ”€โ”€โ”€ React Hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { useAppEvent, usePublishEvent } from './hooks'; diff --git a/packages/core-events/tsconfig.json b/packages/core-events/tsconfig.json new file mode 100644 index 0000000..b18477f --- /dev/null +++ b/packages/core-events/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/react-library.json", + "include": ["src"], + "compilerOptions": { + "strict": true, + "declaration": true, + "declarationMap": true + } +} diff --git a/packages/core-events/vitest.config.ts b/packages/core-events/vitest.config.ts new file mode 100644 index 0000000..c4588ab --- /dev/null +++ b/packages/core-events/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd8343b..87eb7f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@repo/core-api': specifier: workspace:* version: link:../../packages/core-api + '@repo/core-events': + specifier: workspace:* + version: link:../../packages/core-events '@repo/core-i18n': specifier: workspace:* version: link:../../packages/core-i18n @@ -224,7 +227,7 @@ importers: version: 5.4.17(@types/node@22.19.3) vitest: specifier: ^4.0.17 - version: 4.0.17(@opentelemetry/api@1.9.1) + version: 4.0.17(jsdom@26.1.0) packages/configs/eslint: dependencies: @@ -303,6 +306,46 @@ importers: specifier: ^4.0.17 version: 4.0.17(@opentelemetry/api@1.9.1) + packages/core-events: + dependencies: + mitt: + specifier: ^3.0.1 + version: 3.0.1 + devDependencies: + '@repo/eslint-config': + specifier: workspace:* + version: link:../configs/eslint + '@repo/typescript-config': + specifier: workspace:* + version: link:../configs/typescript + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3) + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + eslint: + specifier: ^8.57.1 + version: 8.57.1 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + typescript: + specifier: 5.5.4 + version: 5.5.4 + vitest: + specifier: ^4.0.17 + version: 4.0.17(jsdom@26.1.0) + packages/core-i18n: dependencies: '@repo/core-storage': @@ -351,7 +394,7 @@ importers: version: 5.5.4 vitest: specifier: ^4.0.17 - version: 4.0.17(@opentelemetry/api@1.9.1) + version: 4.0.17(jsdom@26.1.0) packages/ui: dependencies: @@ -412,7 +455,7 @@ importers: version: 5.4.17(@types/node@22.19.3) vitest: specifier: ^4.0.17 - version: 4.0.17(@opentelemetry/api@1.9.1) + version: 4.0.17(jsdom@26.1.0) packages/utils: dependencies: @@ -440,7 +483,7 @@ importers: version: 5.5.4 vitest: specifier: ^4.0.17 - version: 4.0.17(@opentelemetry/api@1.9.1) + version: 4.0.17(jsdom@26.1.0) packages: @@ -448,6 +491,16 @@ packages: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} dev: true + /@asamuzakjp/css-color@3.2.0: + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + dev: true + /@babel/code-frame@7.27.1: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -636,6 +689,49 @@ packages: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + /@csstools/color-helpers@5.1.0: + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + dev: true + + /@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4): + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + dev: true + + /@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4): + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + dev: true + + /@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4): + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + dependencies: + '@csstools/css-tokenizer': 3.0.4 + dev: true + + /@csstools/css-tokenizer@3.0.4: + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + dev: true + /@develar/schema-utils@2.6.5: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -2823,6 +2919,43 @@ packages: tailwindcss: 4.1.18 vite: 5.4.17(@types/node@22.19.3) + /@testing-library/dom@10.4.1: + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + dev: true + + /@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + dev: true + /@tootallnate/once@2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -2836,6 +2969,10 @@ packages: dev: false optional: true + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -3771,6 +3908,11 @@ packages: dependencies: color-convert: 2.0.1 + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + /ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3894,6 +4036,12 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + /aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4620,6 +4768,14 @@ packages: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} dev: false + /cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + dev: true + /csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4627,6 +4783,14 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: false + /data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + dev: true + /data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -4702,6 +4866,10 @@ packages: dependencies: ms: 2.1.3 + /decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dev: true + /decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} dependencies: @@ -4864,6 +5032,10 @@ packages: dependencies: esutils: 2.0.3 + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + /dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -5032,6 +5204,11 @@ packages: graceful-fs: 4.2.11 tapable: 2.3.0 + /entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + dev: true + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -6264,6 +6441,13 @@ packages: lru-cache: 10.4.3 dev: false + /html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + dependencies: + whatwg-encoding: 3.1.1 + dev: true + /html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} dependencies: @@ -6642,6 +6826,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dev: true + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -6799,6 +6987,41 @@ packages: engines: {node: '>=12.0.0'} dev: true + /jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -7130,6 +7353,11 @@ packages: engines: {node: '>=12'} dev: true + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + /magic-string@0.27.0: resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} engines: {node: '>=12'} @@ -7711,6 +7939,10 @@ packages: yallist: 4.0.0 dev: true + /mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + dev: false + /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -7888,6 +8120,10 @@ packages: set-blocking: 2.0.0 dev: true + /nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + dev: true + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8103,6 +8339,12 @@ packages: type-fest: 3.13.1 dev: false + /parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + dependencies: + entities: 6.0.1 + dev: true + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -8226,6 +8468,15 @@ packages: resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} engines: {node: '>=14'} + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + /proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -8388,6 +8639,10 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-number-format@5.4.4(react-dom@19.2.3)(react@19.2.3): resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} peerDependencies: @@ -8825,6 +9080,10 @@ packages: fsevents: 2.3.3 dev: true + /rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -8885,6 +9144,13 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -9389,6 +9655,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9488,6 +9758,17 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + dev: true + + /tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + dependencies: + tldts-core: 6.1.86 + dev: true + /tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} dependencies: @@ -9506,6 +9787,20 @@ packages: is-number: 7.0.0 dev: false + /tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + dependencies: + tldts: 6.1.86 + dev: true + + /tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + dependencies: + punycode: 2.3.1 + dev: true + /trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} dev: false @@ -10233,11 +10528,87 @@ packages: - yaml dev: true + /vitest@4.0.17(jsdom@26.1.0): + resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.17 + '@vitest/browser-preview': 4.0.17 + '@vitest/browser-webdriverio': 4.0.17 + '@vitest/ui': 4.0.17 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@vitest/expect': 4.0.17 + '@vitest/mocker': 4.0.17(vite@7.3.1) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + jsdom: 26.1.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + dev: true + /void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} dev: false + /w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + dependencies: + xml-name-validator: 5.0.0 + dev: true + /walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} dev: false @@ -10252,10 +10623,36 @@ packages: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} dev: false + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + /webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} dev: true + /whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + dev: true + + /whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + dev: true + /which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -10380,12 +10777,21 @@ packages: optional: true dev: true + /xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + dev: true + /xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} requiresBuild: true dev: true + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'}