docs: update README files for core packages with architecture diagrams and usage examples
This commit is contained in:
+68
-54
@@ -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
|
**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](#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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
graph TD
|
||||||
│ @repo/core-api │
|
subgraph Apps ["apps/* (App Autonomy)"]
|
||||||
│ │
|
WEB[apps/web]
|
||||||
│ ┌──────────────┐ ┌───────────────────┐ ┌───────────────────┐ │
|
LAND[apps/landing]
|
||||||
│ │ http-client │ │ observability │ │ data-services │ │
|
DESK[apps/desktop]
|
||||||
│ │ │ │ │ │ │ │
|
end
|
||||||
│ │ createHttp │◄──│ faroAdapter │ │ BaseRemoteData │ │
|
|
||||||
│ │ Client() │ │ initTelemetry() │ │ Services │ │
|
subgraph Core ["@repo/core-api (Engine)"]
|
||||||
│ │ │ │ getFaro() │ │ CommonRemoteData │ │
|
subgraph HTTP ["http-client"]
|
||||||
│ │ ApiResponse │ │ noopAdapter │ │ Services │ │
|
FACTORY[createHttpClient]
|
||||||
│ └──────┬───────┘ └───────────────────┘ └────────┬──────────┘ │
|
end
|
||||||
│ │ │ │
|
subgraph OBS ["observability"]
|
||||||
│ └────────────────────┬───────────────────────┘ │
|
FARO[faroAdapter]
|
||||||
│ │ │
|
end
|
||||||
│ ┌────────┴────────┐ │
|
subgraph DATA ["data-services"]
|
||||||
│ │ errors │ │
|
BASE[BaseRemoteDataServices]
|
||||||
│ │ ApiError │ │
|
COMMON[CommonRemoteDataServices]
|
||||||
│ │ ErrorCodes │ │
|
end
|
||||||
│ └─────────────────┘ │
|
subgraph ERRORS ["errors"]
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
API_ERR[ApiError]
|
||||||
│ │ │
|
end
|
||||||
▼ ▼ ▼
|
end
|
||||||
apps/web apps/landing apps/desktop
|
|
||||||
|
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:
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
Component → DataService.getMany() → execute()
|
sequenceDiagram
|
||||||
→ httpClient.request()
|
participant C as UI Component
|
||||||
→ Request Interceptor:
|
participant S as Data Service
|
||||||
1. faroAdapter.onRequestStart() ← Faro log + optional custom span
|
participant H as HTTP Client
|
||||||
2. hooks.onRequest() ← App-specific (e.g., Bearer token)
|
participant F as Faro Adapter
|
||||||
→ Network (fetch/XHR)
|
participant A as App Hooks (IoC)
|
||||||
→ Response Interceptor:
|
participant N as Network
|
||||||
SUCCESS: faroAdapter.onRequestEnd() → hooks.onResponse()
|
|
||||||
ERROR: faroAdapter.onRequestError() → hooks.onResponseError()
|
C->>S: getMany()
|
||||||
→ ApiError.fromAxiosError()
|
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]
|
> [!IMPORTANT]
|
||||||
@@ -143,10 +157,10 @@ import { initTelemetry } from '@repo/core-api/observability/setup';
|
|||||||
initTelemetry({
|
initTelemetry({
|
||||||
appName: 'fe-monorepo-web',
|
appName: 'fe-monorepo-web',
|
||||||
appVersion: '1.0.0',
|
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',
|
environment: 'production',
|
||||||
// Optional: direct OTLP export to Grafana Tempo
|
// 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({
|
initTelemetry({
|
||||||
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
|
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
|
||||||
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
|
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
|
||||||
telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
|
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',
|
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',
|
environment: import.meta.env.VITE_ENV || 'development',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ declare module '@repo/core-events' {
|
|||||||
'STORE:ORDER_PLACED': OrderPayload;
|
'STORE:ORDER_PLACED': OrderPayload;
|
||||||
'STORE:ORDER_CANCELLED': { orderId: string; reason: string };
|
'STORE:ORDER_CANCELLED': { orderId: string; reason: string };
|
||||||
'UI:SIDEBAR_TOGGLED': { collapsed: boolean };
|
'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();
|
const publish = usePublishEvent();
|
||||||
// ✅ 'STORE:ORDER_PLACED' autocompletes.
|
// ✅ 'STORE:ORDER_PLACED' autocompletes.
|
||||||
// ✅ Payload shape is enforced by TypeScript.
|
// ✅ 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() {
|
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
|
## 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.
|
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.
|
// Fire and forget. Zero knowledge of how printing actually happens.
|
||||||
publish('DEVICE:PRINT_RECEIPT', {
|
publish('DEVICE:PRINT_RECEIPT', {
|
||||||
receiptId: 'RCP-123',
|
receiptId: 'RCP-123',
|
||||||
items: [...],
|
items: [],
|
||||||
total: 45.00,
|
total: 45.00,
|
||||||
cashierName: 'Firman',
|
cashierName: 'Firman',
|
||||||
timestamp: Date.now(),
|
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.
|
**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)**:
|
**Publisher (Profile UI)**:
|
||||||
```tsx
|
```tsx
|
||||||
@@ -249,7 +278,7 @@ export function ProfileSettingsUI() {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
name: 'Firman',
|
name: 'Firman',
|
||||||
email: 'firman@eigen.co.id',
|
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(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -260,14 +289,19 @@ export function ProfileSettingsUI() {
|
|||||||
|
|
||||||
**Subscriber (Storage Sync Listener)**:
|
**Subscriber (Storage Sync Listener)**:
|
||||||
```tsx
|
```tsx
|
||||||
import { useAppEvent } from '@repo/core-events';
|
import { useAppEvent, usePublishEvent } from '@repo/core-events';
|
||||||
import { secureIndexedDB } from '@repo/core-storage';
|
import { secureIndexedDB } from '@repo/core-storage';
|
||||||
|
|
||||||
export function StorageSyncListener() {
|
export function StorageSyncListener() {
|
||||||
|
const publish = usePublishEvent();
|
||||||
|
|
||||||
useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
|
useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
|
||||||
// Automatically encrypted at rest because 'user_profile'
|
// Automatically encrypted at rest because 'user_profile'
|
||||||
// is defined in ENCRYPTED_KEYS in @repo/core-storage
|
// 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;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Enterprise i18n Architecture (`@repo/core-i18n`)
|
# Enterprise i18n Architecture (`@repo/core-i18n`)
|
||||||
|
|
||||||
|
[← Back to Root](../../README.md)
|
||||||
|
|
||||||
A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
|
A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
|
||||||
|
|
||||||
It uses a **Hybrid Namespace Strategy**:
|
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<br/>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)
|
## 1. App-Level Setup (Bootstrap)
|
||||||
|
|
||||||
Initialize the engine *before* your React application mounts to prevent UI flashing.
|
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
|
||||||
|
<h1>{t('booking:messages.welcome', { name: 'Firman', count: 5 })}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.
|
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`).
|
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 🎯 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.
|
* **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**:
|
* **Dual Backend Strategy**:
|
||||||
* `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences).
|
* `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences, settings).
|
||||||
* `IndexedDBService`: Built for large, asynchronous data (offline drafts, cached API responses, blobs) without the 5MB size limit.
|
* `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.
|
* **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.
|
||||||
|
* **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.
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 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<UserProfile>('user_profile')`). |
|
|
||||||
| 🩹 **Corrupt Data Resilience** | If parsing or decryption fails (e.g., tampered data), the corrupt entry is safely removed and returns `null`. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Usage Examples
|
## 🚀 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)
|
### 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
|
```typescript
|
||||||
import { secureStorage, StorageKey } from '@repo/core-storage';
|
import { secureStorage, StorageKey } from '@repo/core-storage';
|
||||||
@@ -46,7 +76,7 @@ await secureStorage.setItem(StorageKey.USER_PROFILE, {
|
|||||||
role: 'admin'
|
role: 'admin'
|
||||||
});
|
});
|
||||||
|
|
||||||
// READ
|
// READ (Returns null if not found or if decryption fails)
|
||||||
const profile = await secureStorage.getItem<UserProfile>(StorageKey.USER_PROFILE);
|
const profile = await secureStorage.getItem<UserProfile>(StorageKey.USER_PROFILE);
|
||||||
if (profile) {
|
if (profile) {
|
||||||
console.log('Welcome back,', profile.name);
|
console.log('Welcome back,', profile.name);
|
||||||
@@ -58,7 +88,7 @@ await secureStorage.removeItem(StorageKey.USER_PROFILE);
|
|||||||
|
|
||||||
### 2. IndexedDB (Offline Data, Large Payloads)
|
### 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
|
```typescript
|
||||||
import { secureIndexedDB } from '@repo/core-storage';
|
import { secureIndexedDB } from '@repo/core-storage';
|
||||||
@@ -69,7 +99,7 @@ interface DraftData {
|
|||||||
lastModified: number;
|
lastModified: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save a large draft offline
|
// Save a large draft offline (No 5MB limit)
|
||||||
await secureIndexedDB.setItem('offline_draft_123', {
|
await secureIndexedDB.setItem('offline_draft_123', {
|
||||||
id: '123',
|
id: '123',
|
||||||
content: 'Huge text content...',
|
content: 'Huge text content...',
|
||||||
@@ -82,9 +112,9 @@ const draft = await secureIndexedDB.getItem<DraftData>('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
|
### 1. Register the Key
|
||||||
|
|
||||||
@@ -111,4 +141,26 @@ export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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'],
|
||||||
|
});
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user