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

This commit is contained in:
Firman Ramdhani
2026-05-28 11:20:35 +07:00
parent 3621f837f6
commit 6fde65f04e
21 changed files with 2021 additions and 7 deletions
+22 -3
View File
@@ -269,7 +269,26 @@ Provides a Hybrid Namespace Architecture combining a centralized i18n engine wit
--- ---
### 8. `packages/utils` ### 8. `packages/core-events`
The **decoupled Nervous System** for the monorepo.
Provides a highly performant, strictly typed Event Bus (Pub/Sub) powered by `mitt`. It allows independent modules to communicate seamlessly without tightly coupling their codebases or triggering expensive global React tree re-renders.
**Key Capabilities**:
| Feature | Description |
|---|---|
| 🧩 Zero Coupling | Publishers and subscribers interact via blind events, eliminating direct module imports and circular dependencies. |
| ⚡ Extreme Performance | Enables targeted DOM updates for high-frequency data streams (e.g., WebSockets) without re-rendering parent components. |
| 🧹 Memory Safety | Native `useAppEvent` hook automatically unsubscribes on component unmount, preventing SPA memory leaks. |
| 🛡️ Strict Contracts | Centralized `events.registry.ts` enforces payload shapes via TypeScript, ensuring cross-module data safety. |
**Documentation**: [README.md](packages/core-events/README.md)
---
### 9. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest. 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). 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. Single source of truth for tooling configuration.
+1
View File
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/core-api": "workspace:*", "@repo/core-api": "workspace:*",
"@repo/core-events": "workspace:*",
"@repo/core-i18n": "workspace:*", "@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*", "@repo/core-storage": "workspace:*",
"@repo/ui": "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'; } from '@repo/ui/components';
import PrinterList from './printer-list'; import PrinterList from './printer-list';
import ExamplePage from './example/example.page'; import ExamplePage from './example/example.page';
import EventsDemoPage from './events-demo';
interface ShowcaseViewProps { interface ShowcaseViewProps {
colorScheme: ColorSchemeType; colorScheme: ColorSchemeType;
@@ -219,6 +220,11 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</Text> </Text>
<ExamplePage /> <ExamplePage />
</Card> </Card>
{/* =========================================
EVENT BUS SHOWCASE
========================================= */}
<EventsDemoPage />
</Stack> </Stack>
</Container> </Container>
); );
+222
View File
@@ -0,0 +1,222 @@
# @repo/core-events
[← Back to Root](../../README.md)
## Overview
`@repo/core-events` is the **decoupled Nervous System** of the ERP. It provides a highly performant, strictly typed Event Bus powered by `mitt` and React hooks.
By routing communication through a centralized event bus, we achieve:
- **Zero Coupling**: Publishers and subscribers don't need to import or know about each other.
- **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets) and update their own local state *without* triggering massive React tree re-renders.
- **Memory Safety**: The provided `useAppEvent` hook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures.
- **Strict Contracts**: The `AppEvents` registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract.
---
## Architecture
```mermaid
graph TD
subgraph Publishers
A[Cashier UI]
B[Profile Settings]
C[WebSocket Client]
end
subgraph Core
E((Event Bus<br/>mitt))
R[[AppEvents<br/>Registry]] -.-> E
end
subgraph Subscribers
X[Electron IPC Bridge]
Y[IndexedDB Sync]
Z[Stock Grid Row]
end
A -- "DEVICE:PRINT_RECEIPT" --> E
B -- "AUTH:PROFILE_UPDATED" --> E
C -- "WS:STOCK_UPDATE" --> E
E -.-> X
E -.-> Y
E -.-> Z
style E fill:#4263eb,color:#fff,stroke:#fff
style R fill:#2b8a3e,color:#fff,stroke:#fff
```
---
## Defining Events
Every event in the system MUST be registered in `src/events.registry.ts`. This provides a single source of truth and full autocomplete across the codebase.
To add a new event, simply extend the `AppEvents` type:
```typescript
// packages/core-events/src/events.registry.ts
export interface CheckoutPayload {
orderId: string;
total: number;
}
export type AppEvents = {
// Existing events...
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
// Your new event:
'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
};
```
---
## Usage Examples
Here are three real-world architectural patterns powered by the Event Bus.
### Example 1: Hardware Abstraction (Cross-Platform)
**Problem**: The web app needs to print receipts. If running in a browser, it should use `window.print()`. If running in the Electron wrapper, it must use the secure IPC bridge (`window.electronAPI.print()`). We don't want the UI components cluttered with platform-detection logic.
**Solution**: The UI publishes a blind event. A headless listener handles the platform routing.
**Publisher (Cashier UI)**:
```tsx
import { usePublishEvent } from '@repo/core-events';
export function CashierUI() {
const publish = usePublishEvent();
const handlePrint = () => {
// Fire and forget. Zero knowledge of how printing actually happens.
publish('DEVICE:PRINT_RECEIPT', {
receiptId: 'RCP-123',
items: [...],
total: 45.00,
cashierName: 'Firman'
});
};
return <Button onClick={handlePrint}>Print Receipt</Button>;
}
```
**Subscriber (Headless Listener)**:
```tsx
import { useAppEvent } from '@repo/core-events';
export function PrinterListener() {
useAppEvent('DEVICE:PRINT_RECEIPT', (payload) => {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
if (isElectron) {
// Route via secure Electron IPC bridge
window.electronAPI.print({ silent: true });
} else {
// Fallback to standard browser print dialog
window.print();
}
});
return null; // Renders nothing
}
```
---
### Example 2: Extreme Performance (High-Frequency Data)
**Problem**: A massive data grid (1,000+ rows) receives 50 WebSocket updates per second. If the parent grid holds the state and passes it down via props, React will attempt to re-render all 1,000 rows 50 times a second, crushing the browser.
**Solution**: The parent grid renders empty rows. Each row subscribes to the event bus and filters updates so it only re-renders when its specific data changes.
**Parent Grid (Never re-renders)**:
```tsx
export function LiveStockGrid() {
// Generates 1000 IDs once. No stock data is stored here!
const stockIds = generateStockIds(1000);
return (
<table>
<tbody>
{stockIds.map((id) => (
<StockRow key={id} stockId={id} />
))}
</tbody>
</table>
);
}
```
**Child Row (Targeted Updates)**:
```tsx
import { memo, useState } from 'react';
import { useAppEvent } from '@repo/core-events';
export const StockRow = memo(function StockRow({ stockId }) {
const [data, setData] = useState(null);
useAppEvent('WS:STOCK_UPDATE', (payload) => {
// CRITICAL: Filter out events for other rows.
// 999 out of 1000 rows will exit here instantly without causing a re-render.
if (payload.id !== stockId) return;
// Only the targeted row updates its local state
setData(payload);
});
return (
<tr>
<td>{stockId}</td>
<td>{data?.price}</td>
</tr>
);
});
```
---
### Example 3: Background Sync (Auth to IndexedDB)
**Problem**: When a user updates their profile, we need to persist it to the secure local IndexedDB. We don't want to tightly couple our UI forms to the `@repo/core-storage` package.
**Solution**: The UI form announces the profile update. A dedicated storage listener persists it in the background.
**Publisher (Profile UI)**:
```tsx
import { usePublishEvent } from '@repo/core-events';
export function ProfileSettingsUI() {
const publish = usePublishEvent();
const handleSave = () => {
publish('AUTH:PROFILE_UPDATED', {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
});
};
return <Button onClick={handleSave}>Save Profile</Button>;
}
```
**Subscriber (Storage Sync Listener)**:
```tsx
import { useAppEvent } from '@repo/core-events';
import { secureIndexedDB } from '@repo/core-storage';
export function StorageSyncListener() {
useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
// Automatically encrypted at rest because 'user_profile'
// is defined in ENCRYPTED_KEYS in @repo/core-storage
secureIndexedDB.setItem('user_profile', payload).catch(console.error);
});
return null;
}
```
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@repo/core-events",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"license": "MIT",
"scripts": {
"lint": "eslint \"**/*.ts\" \"**/*.tsx\"",
"test": "vitest run",
"test:watch": "vitest --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"mitt": "^3.0.1"
},
"peerDependencies": {
"react": ">=18.0.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^8.57.1",
"jsdom": "^26.1.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"typescript": "5.5.4",
"vitest": "^4.0.17"
}
}
+266
View File
@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { eventBus, publish, subscribe } from './event-bus';
import { useAppEvent, usePublishEvent } from './hooks';
import type { StockUpdatePayload, ProfileUpdatedPayload } from './events.registry';
// ─── Helpers ────────────────────────────────────────────────────
/** Clear all mitt handlers between tests to avoid cross-contamination. */
function clearAllHandlers() {
eventBus.all.clear();
}
// ─── Test Data ──────────────────────────────────────────────────
const mockStockUpdate: StockUpdatePayload = {
id: 'AAPL',
price: 185.42,
change: 1.23,
volume: 50000,
};
const mockProfile: ProfileUpdatedPayload = {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
avatar: 'https://example.com/avatar.png',
updatedAt: Date.now(),
};
// ─── Core Event Bus Tests ───────────────────────────────────────
describe('Event Bus (Core)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('publishes and subscribes to a typed event', () => {
const handler = vi.fn();
subscribe('WS:STOCK_UPDATE', handler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
});
it('delivers events to multiple subscribers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
subscribe('AUTH:PROFILE_UPDATED', handler1);
subscribe('AUTH:PROFILE_UPDATED', handler2);
publish('AUTH:PROFILE_UPDATED', mockProfile);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it('does not deliver events to unrelated subscribers', () => {
const stockHandler = vi.fn();
const profileHandler = vi.fn();
subscribe('WS:STOCK_UPDATE', stockHandler);
subscribe('AUTH:PROFILE_UPDATED', profileHandler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(stockHandler).toHaveBeenCalledTimes(1);
expect(profileHandler).not.toHaveBeenCalled();
});
it('unsubscribes correctly via returned function', () => {
const handler = vi.fn();
const unsub = subscribe('WS:STOCK_UPDATE', handler);
publish('WS:STOCK_UPDATE', mockStockUpdate);
expect(handler).toHaveBeenCalledTimes(1);
// Unsubscribe
unsub();
publish('WS:STOCK_UPDATE', mockStockUpdate);
// Should still be 1, not 2
expect(handler).toHaveBeenCalledTimes(1);
});
it('handles events with undefined payloads', () => {
const handler = vi.fn();
subscribe('APP:INITIALIZED', handler);
publish('APP:INITIALIZED', undefined);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(undefined);
});
it('handles rapid-fire events without loss', () => {
const handler = vi.fn();
subscribe('WS:STOCK_UPDATE', handler);
for (let i = 0; i < 1000; i++) {
publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
}
expect(handler).toHaveBeenCalledTimes(1000);
});
});
// ─── React Hook Tests ───────────────────────────────────────────
describe('useAppEvent (React Hook)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('subscribes on mount and receives events', () => {
const handler = vi.fn();
renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
});
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
const handler = vi.fn();
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
// Event should be received while mounted
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
expect(handler).toHaveBeenCalledTimes(1);
// Unmount the component
unmount();
// Event should NOT be received after unmount
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
// Still 1, proving the handler was properly cleaned up
expect(handler).toHaveBeenCalledTimes(1);
});
it('does not leak handlers across mount/unmount cycles', () => {
const handler = vi.fn();
// Mount and unmount 100 times
for (let i = 0; i < 100; i++) {
const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler));
unmount();
}
// After 100 cycles, emit one event
act(() => {
publish('WS:STOCK_UPDATE', mockStockUpdate);
});
// Should be 0 — all handlers should have been cleaned up
expect(handler).toHaveBeenCalledTimes(0);
// Verify the handler map is empty for this event type
const handlers = eventBus.all.get('WS:STOCK_UPDATE');
expect(!handlers || handlers.length === 0).toBe(true);
});
it('always calls the latest handler (no stale closures)', () => {
let capturedValue = '';
const { rerender } = renderHook(
({ value }: { value: string }) =>
useAppEvent('AUTH:PROFILE_UPDATED', () => {
capturedValue = value;
}),
{ initialProps: { value: 'initial' } },
);
// Update the closure value
rerender({ value: 'updated' });
act(() => {
publish('AUTH:PROFILE_UPDATED', mockProfile);
});
// Should capture the LATEST value, not the stale 'initial'
expect(capturedValue).toBe('updated');
});
it('does not re-subscribe when handler reference changes', () => {
// We spy on eventBus.on to count subscription calls
const onSpy = vi.spyOn(eventBus, 'on');
const offSpy = vi.spyOn(eventBus, 'off');
const { rerender } = renderHook(
({ handler }: { handler: () => void }) =>
useAppEvent('APP:INITIALIZED', handler),
{ initialProps: { handler: vi.fn() } },
);
const initialOnCount = onSpy.mock.calls.length;
const initialOffCount = offSpy.mock.calls.length;
// Re-render with a NEW handler function reference
rerender({ handler: vi.fn() });
// on/off should NOT have been called again (ref-based pattern)
expect(onSpy.mock.calls.length).toBe(initialOnCount);
expect(offSpy.mock.calls.length).toBe(initialOffCount);
onSpy.mockRestore();
offSpy.mockRestore();
});
});
describe('usePublishEvent (React Hook)', () => {
beforeEach(() => {
clearAllHandlers();
});
afterEach(() => {
clearAllHandlers();
});
it('returns a working publish function', () => {
const handler = vi.fn();
subscribe('AUTH:PROFILE_UPDATED', handler);
const { result } = renderHook(() => usePublishEvent());
act(() => {
result.current('AUTH:PROFILE_UPDATED', mockProfile);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mockProfile);
});
it('returns a referentially stable function across re-renders', () => {
const { result, rerender } = renderHook(() => usePublishEvent());
const firstRef = result.current;
rerender();
rerender();
rerender();
expect(result.current).toBe(firstRef);
});
});
+67
View File
@@ -0,0 +1,67 @@
import mitt from 'mitt';
import type { AppEvents } from './events.registry';
// ─── Singleton Event Bus ────────────────────────────────────────
/**
* Application-wide event bus — a strictly typed `mitt` instance.
*
* Prefer the `publish` / `subscribe` helper functions or the React
* hooks (`useAppEvent`, `usePublishEvent`) over using this directly.
* Direct access is provided for edge cases like middleware or testing.
*
* @example
* ```ts
* import { eventBus } from '@repo/core-events';
* eventBus.on('APP:ERROR', (e) => console.error(e.message));
* ```
*/
export const eventBus = mitt<AppEvents>();
// ─── Type-Safe Helpers ──────────────────────────────────────────
/**
* Emit (publish) a typed event to all subscribers.
*
* @param type - The event name from `AppEvents`.
* @param event - The payload matching that event's type.
*
* @example
* ```ts
* publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... });
* ```
*/
export function publish<K extends keyof AppEvents>(
type: K,
event: AppEvents[K],
): void {
eventBus.emit(type, event);
}
/**
* Subscribe to a typed event.
*
* Returns an `unsubscribe` function — call it to remove the handler.
* For React components, prefer `useAppEvent` which handles cleanup
* automatically on unmount.
*
* @param type - The event name from `AppEvents`.
* @param handler - Callback receiving the typed payload.
* @returns A function that removes this subscription.
*
* @example
* ```ts
* const unsub = subscribe('WS:STOCK_UPDATE', (data) => {
* console.log(data.price); // fully typed
* });
* // Later:
* unsub();
* ```
*/
export function subscribe<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): () => void {
eventBus.on(type, handler);
return () => eventBus.off(type, handler);
}
@@ -0,0 +1,82 @@
// ─── Event Payload Types ────────────────────────────────────────
/**
* Receipt line item for the DEVICE:PRINT_RECEIPT event.
*/
export interface ReceiptItem {
name: string;
qty: number;
price: number;
}
/**
* Payload for the DEVICE:PRINT_RECEIPT event.
*/
export interface PrintReceiptPayload {
receiptId: string;
items: ReceiptItem[];
total: number;
cashierName: string;
timestamp: number;
}
/**
* Payload for the WS:STOCK_UPDATE event.
*/
export interface StockUpdatePayload {
id: string;
price: number;
change: number;
volume: number;
}
/**
* Payload for the AUTH:PROFILE_UPDATED event.
*/
export interface ProfileUpdatedPayload {
id: string;
name: string;
email: string;
avatar: string;
updatedAt: number;
}
// ─── Application Event Registry ────────────────────────────────
/**
* Central event registry for the entire application.
*
* Every event in the system MUST be declared here with its payload
* type. This provides:
*
* 1. **Compile-time safety** — typos in event names are caught by TS.
* 2. **Payload validation** — publishers and subscribers agree on shape.
* 3. **Discoverability** — `Ctrl+Click` any event to find its contract.
*
* **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`.
*
* **Extensibility**: To add events from feature modules, extend this
* type using intersection:
*
* ```ts
* // In your feature module types:
* type InventoryEvents = {
* 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number };
* };
* // Then merge into AppEvents in this file.
* ```
*/
export type AppEvents = {
// ── Device / Hardware ───────────────────────────────────────────
'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
// ── WebSocket / Real-Time ───────────────────────────────────────
'WS:STOCK_UPDATE': StockUpdatePayload;
// ── Auth / User ─────────────────────────────────────────────────
'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload;
// ── App Lifecycle ───────────────────────────────────────────────
'APP:INITIALIZED': undefined;
'APP:ERROR': { message: string; code?: string };
};
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useRef, useCallback } from 'react';
import type { AppEvents } from './events.registry';
import { eventBus, publish as busPublish } from './event-bus';
// ─── useAppEvent ────────────────────────────────────────────────
/**
* Subscribe to an application event with automatic cleanup on unmount.
*
* The handler is stored in a ref so that:
* 1. The subscription is stable — re-renders don't cause unsubscribe/resubscribe churn.
* 2. The handler always sees the latest closure values (no stale closures).
* 3. The parent component's render cycle is never triggered by the subscription itself.
*
* @param type - The event name from `AppEvents`.
* @param handler - Callback receiving the typed payload. May be updated on re-render.
*
* @example
* ```tsx
* useAppEvent('AUTH:PROFILE_UPDATED', (profile) => {
* console.log(profile.name); // fully typed, auto-cleaned on unmount
* });
* ```
*/
export function useAppEvent<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): void {
// Always keep the latest handler in a ref to avoid stale closures
// and prevent re-subscription on every render.
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
// Create a stable delegate that forwards to the latest handler ref
const delegate = (event: AppEvents[K]) => {
handlerRef.current(event);
};
eventBus.on(type, delegate);
// Cleanup: unsubscribe when the component unmounts or `type` changes.
// This is the critical memory-leak prevention mechanism.
return () => {
eventBus.off(type, delegate);
};
}, [type]);
}
// ─── usePublishEvent ────────────────────────────────────────────
/**
* Returns a strictly typed `publish` function.
*
* The returned function is referentially stable (memoized) so it
* can be safely passed as a prop or used in dependency arrays
* without causing unnecessary re-renders.
*
* @example
* ```tsx
* const publish = usePublishEvent();
*
* const handleClick = () => {
* publish('DEVICE:PRINT_RECEIPT', {
* receiptId: '001',
* items: [{ name: 'Widget', qty: 2, price: 9.99 }],
* total: 19.98,
* cashierName: 'Firman',
* timestamp: Date.now(),
* });
* };
* ```
*/
export function usePublishEvent(): typeof busPublish {
return useCallback(busPublish, []);
}
+14
View File
@@ -0,0 +1,14 @@
// ─── Event Registry ─────────────────────────────────────────────
export type {
AppEvents,
PrintReceiptPayload,
StockUpdatePayload,
ProfileUpdatedPayload,
ReceiptItem,
} from './events.registry';
// ─── Event Bus (Core) ───────────────────────────────────────────
export { eventBus, publish, subscribe } from './event-bus';
// ─── React Hooks ────────────────────────────────────────────────
export { useAppEvent, usePublishEvent } from './hooks';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/react-library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
+410 -4
View File
@@ -161,6 +161,9 @@ importers:
'@repo/core-api': '@repo/core-api':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core-api version: link:../../packages/core-api
'@repo/core-events':
specifier: workspace:*
version: link:../../packages/core-events
'@repo/core-i18n': '@repo/core-i18n':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core-i18n version: link:../../packages/core-i18n
@@ -224,7 +227,7 @@ importers:
version: 5.4.17(@types/node@22.19.3) version: 5.4.17(@types/node@22.19.3)
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1) version: 4.0.17(jsdom@26.1.0)
packages/configs/eslint: packages/configs/eslint:
dependencies: dependencies:
@@ -303,6 +306,46 @@ importers:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1) 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: packages/core-i18n:
dependencies: dependencies:
'@repo/core-storage': '@repo/core-storage':
@@ -351,7 +394,7 @@ importers:
version: 5.5.4 version: 5.5.4
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1) version: 4.0.17(jsdom@26.1.0)
packages/ui: packages/ui:
dependencies: dependencies:
@@ -412,7 +455,7 @@ importers:
version: 5.4.17(@types/node@22.19.3) version: 5.4.17(@types/node@22.19.3)
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1) version: 4.0.17(jsdom@26.1.0)
packages/utils: packages/utils:
dependencies: dependencies:
@@ -440,7 +483,7 @@ importers:
version: 5.5.4 version: 5.5.4
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1) version: 4.0.17(jsdom@26.1.0)
packages: packages:
@@ -448,6 +491,16 @@ packages:
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
dev: true 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: /@babel/code-frame@7.27.1:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -636,6 +689,49 @@ packages:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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: /@develar/schema-utils@2.6.5:
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'} engines: {node: '>= 8.9.0'}
@@ -2823,6 +2919,43 @@ packages:
tailwindcss: 4.1.18 tailwindcss: 4.1.18
vite: 5.4.17(@types/node@22.19.3) 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: /@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -2836,6 +2969,10 @@ packages:
dev: false dev: false
optional: true optional: true
/@types/aria-query@5.0.4:
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
dev: true
/@types/babel__core@7.20.5: /@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies: dependencies:
@@ -3771,6 +3908,11 @@ packages:
dependencies: dependencies:
color-convert: 2.0.1 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: /ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -3894,6 +4036,12 @@ packages:
/argparse@2.0.1: /argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 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: /aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4620,6 +4768,14 @@ packages:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
dev: false 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: /csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -4627,6 +4783,14 @@ packages:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: false 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: /data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4702,6 +4866,10 @@ packages:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
/decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dev: true
/decode-named-character-reference@1.2.0: /decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
dependencies: dependencies:
@@ -4864,6 +5032,10 @@ packages:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
/dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dev: true
/dotenv-expand@11.0.7: /dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -5032,6 +5204,11 @@ packages:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 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: /env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -6264,6 +6441,13 @@ packages:
lru-cache: 10.4.3 lru-cache: 10.4.3
dev: false 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: /html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
dependencies: dependencies:
@@ -6642,6 +6826,10 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true dev: true
/is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
dev: true
/is-regex@1.2.1: /is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -6799,6 +6987,41 @@ packages:
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
dev: true 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: /jsesc@0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true hasBin: true
@@ -7130,6 +7353,11 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: true dev: true
/lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
dev: true
/magic-string@0.27.0: /magic-string@0.27.0:
resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -7711,6 +7939,10 @@ packages:
yallist: 4.0.0 yallist: 4.0.0
dev: true dev: true
/mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
dev: false
/mkdirp@1.0.4: /mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -7888,6 +8120,10 @@ packages:
set-blocking: 2.0.0 set-blocking: 2.0.0
dev: true dev: true
/nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
dev: true
/object-assign@4.1.1: /object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -8103,6 +8339,12 @@ packages:
type-fest: 3.13.1 type-fest: 3.13.1
dev: false 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: /path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -8226,6 +8468,15 @@ packages:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'} 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: /proc-log@4.2.0:
resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -8388,6 +8639,10 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false 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): /react-number-format@5.4.4(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
peerDependencies: peerDependencies:
@@ -8825,6 +9080,10 @@ packages:
fsevents: 2.3.3 fsevents: 2.3.3
dev: true dev: true
/rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
dev: true
/run-parallel@1.2.0: /run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies: dependencies:
@@ -8885,6 +9144,13 @@ packages:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'} 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: /scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -9389,6 +9655,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: true
/synckit@0.11.11: /synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@@ -9488,6 +9758,17 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
dev: true 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: /tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
dependencies: dependencies:
@@ -9506,6 +9787,20 @@ packages:
is-number: 7.0.0 is-number: 7.0.0
dev: false 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: /trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
dev: false dev: false
@@ -10233,11 +10528,87 @@ packages:
- yaml - yaml
dev: true 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: /void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: false 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: /walk-up-path@3.0.1:
resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==}
dev: false dev: false
@@ -10252,10 +10623,36 @@ packages:
resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
dev: false 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: /webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
dev: true 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: /which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -10380,12 +10777,21 @@ packages:
optional: true optional: true
dev: true dev: true
/xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
dev: true
/xmlbuilder@15.1.1: /xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
requiresBuild: true requiresBuild: true
dev: true dev: true
/xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: true
/y18n@5.0.8: /y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}