From c510feadbb8e79165de4c2f0eae525e93e72d8ae Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 28 May 2026 12:20:53 +0700 Subject: [PATCH] docs: update README files for core packages with architecture diagrams and usage examples --- packages/core-api/README.md | 124 ++++++++++++++++++-------------- packages/core-events/README.md | 48 +++++++++++-- packages/core-i18n/README.md | 74 ++++++++++++++++++- packages/core-storage/README.md | 96 +++++++++++++++++++------ 4 files changed, 255 insertions(+), 87 deletions(-) diff --git a/packages/core-api/README.md b/packages/core-api/README.md index d867f50..9ca1f74 100644 --- a/packages/core-api/README.md +++ b/packages/core-api/README.md @@ -1,65 +1,79 @@ -# @repo/core-api +# Enterprise API Engine (`@repo/core-api`) -The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine — consumed by `apps/web`, `apps/landing`, and any future workspace. +[← Back to Root](../../README.md) ---- +The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine. -## Table of Contents - -- [Architecture Overview](#architecture-overview) -- [HTTP Client](#http-client) -- [Observability](#observability) -- [Data Services](#data-services) -- [Application Setup Guide](#application-setup-guide) -- [Per-Request Telemetry (Escape Hatch)](#per-request-telemetry-escape-hatch) -- [Error Handling](#error-handling) -- [Package Exports](#package-exports) +**This package enforces App Autonomy (IoC).** The core provides the engine and interceptor pipelines, but the consuming applications (`apps/web`, `apps/landing`) inject their own specific configurations, authentication tokens, and error handling behaviors. --- ## Architecture Overview -``` -┌────────────────────────────────────────────────────────────────────┐ -│ @repo/core-api │ -│ │ -│ ┌──────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ http-client │ │ observability │ │ data-services │ │ -│ │ │ │ │ │ │ │ -│ │ createHttp │◄──│ faroAdapter │ │ BaseRemoteData │ │ -│ │ Client() │ │ initTelemetry() │ │ Services │ │ -│ │ │ │ getFaro() │ │ CommonRemoteData │ │ -│ │ ApiResponse │ │ noopAdapter │ │ Services │ │ -│ └──────┬───────┘ └───────────────────┘ └────────┬──────────┘ │ -│ │ │ │ -│ └────────────────────┬───────────────────────┘ │ -│ │ │ -│ ┌────────┴────────┐ │ -│ │ errors │ │ -│ │ ApiError │ │ -│ │ ErrorCodes │ │ -│ └─────────────────┘ │ -└────────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - apps/web apps/landing apps/desktop +```mermaid +graph TD + subgraph Apps ["apps/* (App Autonomy)"] + WEB[apps/web] + LAND[apps/landing] + DESK[apps/desktop] + end + + subgraph Core ["@repo/core-api (Engine)"] + subgraph HTTP ["http-client"] + FACTORY[createHttpClient] + end + subgraph OBS ["observability"] + FARO[faroAdapter] + end + subgraph DATA ["data-services"] + BASE[BaseRemoteDataServices] + COMMON[CommonRemoteDataServices] + end + subgraph ERRORS ["errors"] + API_ERR[ApiError] + end + end + + WEB & LAND & DESK -->|instantiates| FACTORY + WEB & LAND & DESK -->|extends| COMMON + COMMON -->|executes via| FACTORY + FACTORY -.->|reports via| FARO + FACTORY -.->|throws| API_ERR + + style Core fill:#f8f9fa,stroke:#ced4da + style Apps fill:#e9ecef,stroke:#adb5bd ``` -### Data Flow +### Data Flow Lifecycle -Every HTTP request flows through this pipeline: +Every HTTP request flows through this precise interceptor pipeline: -``` -Component → DataService.getMany() → execute() - → httpClient.request() - → Request Interceptor: - 1. faroAdapter.onRequestStart() ← Faro log + optional custom span - 2. hooks.onRequest() ← App-specific (e.g., Bearer token) - → Network (fetch/XHR) - → Response Interceptor: - SUCCESS: faroAdapter.onRequestEnd() → hooks.onResponse() - ERROR: faroAdapter.onRequestError() → hooks.onResponseError() - → ApiError.fromAxiosError() +```mermaid +sequenceDiagram + participant C as UI Component + participant S as Data Service + participant H as HTTP Client + participant F as Faro Adapter + participant A as App Hooks (IoC) + participant N as Network + + C->>S: getMany() + S->>H: request() + H->>F: onRequestStart() (Log + Span) + H->>A: hooks.onRequest() (Inject Token) + A->>N: fetch/XHR + + alt Success + N-->>A: 200 OK + A->>F: onRequestEnd() (Close Span) + F->>A: hooks.onResponse() + A-->>S: return data + else Error + N-->>A: 401 / 500 + A->>F: onRequestError() (Log Error) + F->>A: hooks.onResponseError() (Redirect/Refresh) + A-->>S: throw ApiError + end ``` > [!IMPORTANT] @@ -143,10 +157,10 @@ import { initTelemetry } from '@repo/core-api/observability/setup'; initTelemetry({ appName: 'fe-monorepo-web', appVersion: '1.0.0', - telemetryUrl: 'https://telemetry.eigen.co.id/collect', + telemetryUrl: '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)', environment: 'production', // Optional: direct OTLP export to Grafana Tempo - otlpTraceUrl: 'https://telemetry.eigen.co.id/v1/traces', + otlpTraceUrl: '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)', }); ``` @@ -254,8 +268,8 @@ import { initTelemetry } from '@repo/core-api/observability/setup'; initTelemetry({ appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web', appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0', - telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect', - otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces', + telemetryUrl: import.meta.env.VITE_FARO_URL || '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)', + otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)', environment: import.meta.env.VITE_ENV || 'development', }); @@ -422,4 +436,4 @@ try { | `@repo/core-api/observability` | `faroAdapter`, `noopObservabilityAdapter`, `IObservabilityAdapter`, `initTelemetry`, `getFaro`, `TelemetryConfig` | | `@repo/core-api/observability/setup` | `initTelemetry`, `getFaro`, `TelemetryConfig` | | `@repo/core-api/data-services` | `BaseRemoteDataServices`, `CommonRemoteDataServices`, types, constants | -| `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` | +| `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` | \ No newline at end of file diff --git a/packages/core-events/README.md b/packages/core-events/README.md index 0c9dcc5..49e9f73 100644 --- a/packages/core-events/README.md +++ b/packages/core-events/README.md @@ -89,6 +89,12 @@ declare module '@repo/core-events' { 'STORE:ORDER_PLACED': OrderPayload; 'STORE:ORDER_CANCELLED': { orderId: string; reason: string }; 'UI:SIDEBAR_TOGGLED': { collapsed: boolean }; + + // Explicit payloads for the examples below: + 'DEVICE:PRINT_RECEIPT': { receiptId: string; items: any[]; total: number; cashierName: string; timestamp: number }; + 'WS:STOCK_UPDATE': { id: string; price: number }; + 'AUTH:PROFILE_UPDATED': { id: string; name: string; email: string; avatar: string; updatedAt: number }; + 'SYSTEM:ERROR': { source: string; error: Error }; } } ``` @@ -102,7 +108,7 @@ function CheckoutButton() { const publish = usePublishEvent(); // ✅ 'STORE:ORDER_PLACED' autocompletes. // ✅ Payload shape is enforced by TypeScript. - publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [...] }); + publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [] }); } function OrderTracker() { @@ -124,6 +130,29 @@ function OrderTracker() { --- +## Usage Outside React (Vanilla TS) + +For utility files, API interceptors, Web Workers, or vanilla functions where React hooks cannot be used, import the raw `eventBus` instance directly. + +```ts +import { eventBus } from '@repo/core-events'; + +// Publishing +eventBus.publish('STORE:ORDER_CANCELLED', { orderId: '123', reason: 'Out of stock' }); + +// Subscribing +const handler = (payload) => { + console.log('Order cancelled:', payload.orderId); +}; + +eventBus.subscribe('STORE:ORDER_CANCELLED', handler); + +// CRITICAL: Always unsubscribe when done to prevent memory leaks in non-React contexts! +eventBus.unsubscribe('STORE:ORDER_CANCELLED', handler); +``` + +--- + ## Usage Examples Here are three real-world architectural patterns powered by the Event Bus. All event types below are registered in `apps/web/src/types/events.d.ts`, **not** in the core package. @@ -145,7 +174,7 @@ export function CashierUI() { // Fire and forget. Zero knowledge of how printing actually happens. publish('DEVICE:PRINT_RECEIPT', { receiptId: 'RCP-123', - items: [...], + items: [], total: 45.00, cashierName: 'Firman', timestamp: Date.now(), @@ -235,7 +264,7 @@ export const StockRow = memo(function StockRow({ stockId }) { **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. +**Solution**: The UI form announces the profile update. A dedicated storage listener persists it in the background, properly escalating errors if the storage fails. **Publisher (Profile UI)**: ```tsx @@ -249,7 +278,7 @@ export function ProfileSettingsUI() { id: 'user-1', name: 'Firman', email: 'firman@eigen.co.id', - avatar: 'https://example.com/avatar.png', + avatar: '[https://example.com/avatar.png](https://example.com/avatar.png)', updatedAt: Date.now(), }); }; @@ -260,16 +289,21 @@ export function ProfileSettingsUI() { **Subscriber (Storage Sync Listener)**: ```tsx -import { useAppEvent } from '@repo/core-events'; +import { useAppEvent, usePublishEvent } from '@repo/core-events'; import { secureIndexedDB } from '@repo/core-storage'; export function StorageSyncListener() { + const publish = usePublishEvent(); + 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); + secureIndexedDB.setItem('user_profile', payload).catch((error) => { + // Escalate to global error handler instead of swallowing it + publish('SYSTEM:ERROR', { source: 'StorageSyncListener', error }); + }); }); return null; } -``` +``` \ No newline at end of file diff --git a/packages/core-i18n/README.md b/packages/core-i18n/README.md index c34d7ba..944d53b 100644 --- a/packages/core-i18n/README.md +++ b/packages/core-i18n/README.md @@ -1,5 +1,7 @@ # Enterprise i18n Architecture (`@repo/core-i18n`) +[← Back to Root](../../README.md) + A highly decoupled, type-safe internationalization engine for the Eigen Monorepo. It uses a **Hybrid Namespace Strategy**: @@ -10,6 +12,43 @@ This architecture strictly adheres to **Inversion of Control (IoC)**. The core e --- +## Overview Architecture + +```mermaid +graph TD + subgraph Apps ["apps/* (App Autonomy)"] + UI[React Components] + DICT[Feature Dictionaries
e.g., booking.json] + end + + subgraph Core ["@repo/core-i18n (Engine)"] + I18N((i18next Instance)) + STORE[(core-storage)] + COMMON[Common Vocabulary] + end + + subgraph Backend ["Backend API"] + SYNC[Language Sync Endpoint] + TENANT[Tenant Config Endpoint] + end + + UI -->|uses useTranslation| I18N + DICT -.->|lazy loads| I18N + COMMON -->|preloads| I18N + I18N <-->|reads/persists| STORE + + I18N -->|changeLanguage sync| SYNC + SYNC -.->|fails? rollback| I18N + + TENANT -.->|applyTenantOverrides| I18N + + style I18N fill:#4263eb,color:#fff,stroke:#fff + style Apps fill:#f8f9fa,stroke:#ced4da + style Core fill:#f8f9fa,stroke:#ced4da +``` + +--- + ## 1. App-Level Setup (Bootstrap) Initialize the engine *before* your React application mounts to prevent UI flashing. @@ -84,9 +123,38 @@ export default function BookingFeature() { } ``` +**3. Dynamic Variables (Interpolation):** +```json +// booking.json +{ + "messages": { + "welcome": "Welcome back, {{name}}! You have {{count}} new bookings." + } +} +``` +```tsx +// Inside component +

{t('booking:messages.welcome', { name: 'Firman', count: 5 })}

+``` + --- -## 3. Real-World Implementation Flow +## 3. Usage Outside React Components (Vanilla TS) + +For utility files, API interceptors, or vanilla functions where React hooks cannot be used, import the raw `i18n` instance directly. + +```ts +import { i18n } from '@repo/core-i18n'; + +// Must specify the namespace explicitly if it's not 'common' +export const getErrorMessage = (code: string) => { + return i18n.t(`booking:errors.${code}`, { defaultValue: 'Unknown Error' }); +}; +``` + +--- + +## 4. Real-World Implementation Flow The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization. @@ -151,7 +219,7 @@ const handleSwitch = async (newLng: string) => { --- -## 4. Backend API Contract (For Backend Engineers) +## 5. Backend API Contract (For Backend Engineers) To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`). @@ -180,4 +248,4 @@ If the frontend dictionary has `header.title` and `header.subtitle`, and the bac } } } -``` +``` \ No newline at end of file diff --git a/packages/core-storage/README.md b/packages/core-storage/README.md index 8d3b5d4..ab09a96 100644 --- a/packages/core-storage/README.md +++ b/packages/core-storage/README.md @@ -1,4 +1,6 @@ -# @repo/core-storage +# Enterprise Storage Engine (`@repo/core-storage`) + +[← Back to Root](../../README.md) The **Enterprise-grade storage engine** for the monorepo. @@ -6,33 +8,61 @@ This package provides a unified, Promise-based interface for interacting with br --- +## Architecture & Data Flow + +```mermaid +graph TD + subgraph Apps ["apps/* (Consumers)"] + UI[React Components / API Interceptors] + end + + subgraph Core ["@repo/core-storage (Engine)"] + API[IStorageService API] + REG[[StorageKey & ENCRYPTED_KEYS Registry]] + ENC{{AES Encryption Pipeline}} + LOCAL[LocalStorage Adapter] + IDB[IndexedDB Adapter] + end + + subgraph Browser ["Browser APIs"] + B_LOCAL[(localStorage)] + B_IDB[(IndexedDB)] + end + + UI -->|getItem / setItem| API + API --> REG + REG -.->|Sensitive Key?| ENC + ENC -.-> LOCAL & IDB + REG -.->|Plain-text Key| LOCAL & IDB + + LOCAL <--> B_LOCAL + IDB <--> B_IDB + + style Core fill:#f8f9fa,stroke:#ced4da + style ENC fill:#fab005,color:#fff,stroke:#fff + style Browser fill:#e9ecef,stroke:#adb5bd +``` + +--- + ## 🎯 Primary Goals & Separation of Concerns * **Separation from `@repo/core-api`**: Storage is a fundamental primitive. While the API client uses storage (to retrieve tokens), storage itself does not need to know about HTTP requests. * **Dual Backend Strategy**: - * `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences). - * `IndexedDBService`: Built for large, asynchronous data (offline drafts, cached API responses, blobs) without the 5MB size limit. + * `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences, settings). + * `secureIndexedDB` (IndexedDB): Built for large, asynchronous data (offline drafts, cached API responses, large arrays/blobs) bypassing the 5MB localStorage limit. * **Security by Default**: Developers don't need to manually encrypt/decrypt data. If a key is marked as sensitive, the library handles AES encryption transparently. - ---- - -## ✨ Key Features - -| Feature | Description | -|---|---| -| 🔒 **Selective Encryption** | Uses `@repo/utils` `EncryptionUtils` to automatically AES-encrypt payloads whose keys are listed in `ENCRYPTED_KEYS`. | -| 🛡️ **Type-Safe Keys** | All keys must be registered in `storage.key.ts`. Prevents typos and key collisions across the monorepo. | -| 🔄 **Unified Promise API** | Both `localStorage` and `IndexedDB` implement the same async `IStorageService` interface. | -| 🧬 **Strict Generics** | Read and write operations enforce payload types via generics (e.g., `getItem('user_profile')`). | -| 🩹 **Corrupt Data Resilience** | If parsing or decryption fails (e.g., tampered data), the corrupt entry is safely removed and returns `null`. | +* **Corrupt Data Resilience**: If parsing or decryption fails (e.g., tampered data or changed encryption keys), the corrupt entry is safely removed and returns `null`, preventing the app from crashing. --- ## 🚀 Usage Examples +Because this package is framework-agnostic, these instances can be imported anywhere: React components, Redux/Zustand stores, or Axios interceptors. + ### 1. Secure Local Storage (Tokens, Profile) -Use `secureStorage` (or your app's configured instance) for small payloads. If the key is in `ENCRYPTED_KEYS`, it will be encrypted at rest. +Use `secureStorage` for small payloads. If the key is in `ENCRYPTED_KEYS`, it will be encrypted at rest automatically. ```typescript import { secureStorage, StorageKey } from '@repo/core-storage'; @@ -46,7 +76,7 @@ await secureStorage.setItem(StorageKey.USER_PROFILE, { role: 'admin' }); -// READ +// READ (Returns null if not found or if decryption fails) const profile = await secureStorage.getItem(StorageKey.USER_PROFILE); if (profile) { console.log('Welcome back,', profile.name); @@ -58,7 +88,7 @@ await secureStorage.removeItem(StorageKey.USER_PROFILE); ### 2. IndexedDB (Offline Data, Large Payloads) -Use the pre-configured `secureIndexedDB` (or instantiate your own) for large, asynchronous data. It uses the exact same API and encryption pipeline. +Use the pre-configured `secureIndexedDB` for large, asynchronous data. It uses the exact same `IStorageService` interface and encryption pipeline as local storage. ```typescript import { secureIndexedDB } from '@repo/core-storage'; @@ -69,7 +99,7 @@ interface DraftData { lastModified: number; } -// Save a large draft offline +// Save a large draft offline (No 5MB limit) await secureIndexedDB.setItem('offline_draft_123', { id: '123', content: 'Huge text content...', @@ -82,9 +112,9 @@ const draft = await secureIndexedDB.getItem('offline_draft_123'); --- -## 🔑 Adding New Keys +## 🔑 Adding New Keys (Current Registry Pattern) -To maintain type safety and avoid collisions, **all** `localStorage` keys must be registered in `packages/core-storage/src/storage.key.ts`. +To maintain type safety and avoid collisions across the monorepo, **all** global storage keys must currently be registered in `packages/core-storage/src/storage.key.ts`. ### 1. Register the Key @@ -111,4 +141,26 @@ export const ENCRYPTED_KEYS: ReadonlySet = new Set([ ``` > [!WARNING] -> If you add an existing plain-text key to `ENCRYPTED_KEYS`, existing users will experience a parsing failure on their next session (because the library expects encrypted data but finds plain text). The resilient parser will catch this and clear the key, effectively logging them out or resetting the preference. +> **Migration Hazard**: If you add an existing plain-text key to `ENCRYPTED_KEYS`, existing users will experience a parsing failure on their next session (because the library expects encrypted data but finds plain text). The resilient parser will catch this and gracefully clear the key, which may effectively log them out or reset their local preference. + +--- + +## 🚧 Planned Updates: Inversion of Control (IoC) Refactor + +**Current Limitation:** Currently, the `StorageKey` and `ENCRYPTED_KEYS` registries live inside `@repo/core-storage`. This violates the strict **App Autonomy (IoC)** principle established in other core packages (like `@repo/core-events`), as consuming applications must modify the core package to register their app-specific keys. + +**Future Architecture Roadmap:** In a future major update, this package will be refactored into a pure Factory/Engine pattern to fully decouple it from application business logic. + +1. The core will export a generic `StorageEngine` class or `createStorage()` factory. +2. Apps (`apps/web`, `apps/landing`) will instantiate their own storage engines and define their own key registries and encryption rules autonomously. + +*Proposed Future API:* +```typescript +// apps/web/src/lib/storage.ts +import { StorageEngine } from '@repo/core-storage'; + +export const webStorage = new StorageEngine({ + prefix: 'web_erp_', + encryptedKeys: ['access_token', 'user_profile'], +}); +``` \ No newline at end of file