Merge pull request 'feat/core-function' (#7) from feat/core-function into main

Reviewed-on: eigen/fe-monorepo-template#7
This commit is contained in:
2026-05-23 00:22:30 +00:00
66 changed files with 5614 additions and 82 deletions
+3
View File
@@ -41,3 +41,6 @@ storybook-static
out/ out/
release/ release/
web-dist/ web-dist/
# Legacy Code (if applicable)
legacy/
+68 -3
View File
@@ -33,6 +33,9 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
│ └── docs-dev/ # Component Documentation & Playground (Storybook) │ └── docs-dev/ # Component Documentation & Playground (Storybook)
├── packages/ ├── packages/
│ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine
│ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption)
│ ├── core-i18n/ # Enterprise Internationalization Architecture
│ ├── ui/ # Shared UI Component Library │ ├── ui/ # Shared UI Component Library
│ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc) │ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc)
│ └── configs/ # Shared Tooling Configurations │ └── configs/ # Shared Tooling Configurations
@@ -204,7 +207,69 @@ An isolated environment for developing and documenting UI components.
--- ---
### 5. `packages/utils` ### 5. `packages/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
* Centralizes all `@grafana/faro-*` and `@opentelemetry/*` dependencies
* Provides plug-and-play telemetry via `initTelemetry()` + `faroAdapter`
**Tech Stack**:
* Axios (isolated instances, zero singleton pollution)
* Grafana Faro (RUM, Logs, Error tracking)
* OpenTelemetry (custom spans, distributed tracing)
* TypeScript (strict types, module augmentation)
**Key Capabilities**:
| Feature | Description |
|---|---|
| 🏭 HTTP Client Factory | `createHttpClient()` — per-app isolated Axios instances with interceptor hooks |
| 📡 Faro/Loki Baseline | Every request automatically pushes structured logs with `module.key` and `module.action` |
| 🎯 Custom Spans (Opt-In) | `telemetryContext.customSpanName` creates explicit OTel spans visible in Grafana Tempo |
| 🛡️ Error Normalization | `ApiError.fromAxiosError()` — structured, serializable error codes for all failure modes |
| 📦 Data Services Engine | `CommonRemoteDataServices` — full CRUD + lifecycle operations with zero boilerplate |
**Documentation**:
| Document | Contents |
|---|---|
| [README.md](packages/core-api/README.md) | Architecture, HTTP client setup, observability strategy, data services, app integration guide |
---
### 6. `packages/core-storage`
The **Enterprise-grade storage engine** for the monorepo.
Provides a unified, Promise-based interface for interacting with browser storage (`localStorage` and `IndexedDB`). Enforces strict type safety, prevents key collisions via a centralized registry, and automatically provides **AES encryption at rest** for sensitive payloads using `@repo/utils`.
**Documentation**: [README.md](packages/core-storage/README.md)
---
### 7. `packages/core-i18n`
The **Enterprise Internationalization Architecture** for the monorepo.
Provides a Hybrid Namespace Architecture combining a centralized i18n engine with decentralized, lazy-loaded feature dictionaries. Features strict TypeScript typings (including nested keys), optional backend synchronization with automatic error rollbacks, and a deep-merge mechanism for dynamic tenant-specific vocabulary overrides.
**Key Capabilities**:
| Feature | Description |
|---|---|
| 🌐 Hybrid Namespaces | Centralized `common` corpus + lazy-loaded feature dictionaries. |
| 🛡️ Strict Typings | Native TS autocomplete for nested paths (e.g., `header.title`) via module augmentation. |
| 🔄 Safe Backend Sync | `changeLanguage` accepts a `syncCallback` with built-in rollback if the API fails. |
| 🏢 Tenant Overrides | `applyTenantOverrides` performs a partial deep-merge to selectively override terminology. |
**Documentation**: [README.md](packages/core-i18n/README.md)
---
### 8. `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.
@@ -212,7 +277,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
--- ---
### 6. `packages/ui` ### 9. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts). Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -221,7 +286,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
--- ---
### 7. `packages/configs` ### 10. `packages/configs`
Single source of truth for tooling configuration. Single source of truth for tooling configuration.
+4
View File
@@ -11,11 +11,15 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"@repo/utils": "workspace:*", "@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"i18next": "^24.2.2",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^15.4.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"devDependencies": { "devDependencies": {
+7
View File
@@ -1,5 +1,7 @@
import { ThemeProvider } from '@repo/ui/provider'; import { ThemeProvider } from '@repo/ui/provider';
import { Button } from '@repo/ui/components'; import { Button } from '@repo/ui/components';
import LandingSample from './features/public-content/presentation/LandingSample';
import I18nLandingSample from './presentation/I18nLandingSample';
export default function App() { export default function App() {
return ( return (
@@ -31,7 +33,12 @@ export default function App() {
@repo/ui workspace link verified Button component rendered successfully. @repo/ui workspace link verified Button component rendered successfully.
</p> </p>
</div> </div>
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<LandingSample />
</div> </div>
<I18nLandingSample />
</div>
</ThemeProvider> </ThemeProvider>
); );
} }
@@ -0,0 +1,40 @@
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { publicClient } from '../../../lib/api-client';
// ─── Domain Entity ──────────────────────────────────────────────
/**
* Public content entity for the landing page.
*
* Represents promotional content, blog posts, or announcements
* served from a public API without authentication.
*/
export interface PublicContentEntity extends BaseEntity {
title: string;
slug: string;
excerpt: string;
imageUrl: string;
publishedAt: string;
}
// ─── Data Services Instance ─────────────────────────────────────
/**
* Public content data services — wired to the lightweight `publicClient`.
*
* No auth, no OTel — pure zero-overhead HTTP calls.
*
* @example
* ```ts
* const { data } = await publicContentServices.getMany();
* const { data: post } = await publicContentServices.getOne('hello-world');
* ```
*/
export const publicContentServices = new CommonRemoteDataServices<PublicContentEntity>(
publicClient,
{
apiUrl: '/content',
moduleKey: 'PUBLIC_CONTENT',
},
);
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { publicContentServices } from '../data/public.data-services';
import type { PublicContentEntity } from '../data/public.data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { ApiError } from '@repo/core-api/errors';
/**
* Sample component demonstrating the `@repo/core-api` integration
* for the Landing App with TelemetryContext escape hatch.
*
* Pipeline: Faro auto-instrumentation → No Auth → GET /content
* + Custom span "public.content.fetch" with enriched tags
*/
export default function LandingSample() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ApiResponse<PublicContentEntity[]> | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFetch = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
const response = await publicContentServices.getMany<PublicContentEntity[]>({
// ── Telemetry Escape Hatch ──────────────────────────────
// Even the lightweight landing app can push custom spans
// and business events when needed.
telemetryContext: {
customSpanName: 'public.content.fetch',
tags: {
'feature': 'landing',
'ui.component': 'LandingSample',
'ui.action': 'content_fetch',
},
pushEventOnSuccess: 'public_content_loaded',
},
});
setResult(response);
console.log('[LandingSample] Response:', response);
} catch (err) {
if (err instanceof ApiError) {
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
console.error('[LandingSample] ApiError:', err.toJSON());
} else {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 24, fontFamily: 'monospace' }}>
<h2>🧪 Public Content Services Integration Test</h2>
<p style={{ color: '#888', fontSize: 14 }}>
Pipeline: Faro + Custom Span &quot;public.content.fetch&quot; No Auth GET /content
</p>
<button
onClick={handleFetch}
disabled={loading}
style={{
padding: '10px 20px',
fontSize: 16,
cursor: loading ? 'wait' : 'pointer',
background: loading ? '#555' : '#059669',
color: '#fff',
border: 'none',
borderRadius: 6,
marginTop: 12,
}}
>
{loading ? 'Fetching…' : 'Test Public Fetch'}
</button>
{error && (
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{result && (
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
/**
* Lightweight public HTTP client for `apps/landing`.
*
* - Uses the shared Faro adapter (initialized via `initTelemetry()` in main.tsx)
* - No auth token injection
* - Minimal config for maximum performance
*
* Designed for public-facing content that requires no authentication.
*/
export const publicClient = createHttpClient({
baseURL: import.meta.env.VITE_API_URL ?? 'https://api.eigen.co/public/v1',
timeout: 10000,
observability: faroAdapter,
});
+4
View File
@@ -0,0 +1,4 @@
{
"welcome": "Welcome to Our Product",
"cta": "Get Started Now"
}
+4
View File
@@ -0,0 +1,4 @@
{
"welcome": "Selamat Datang di Produk Kami",
"cta": "Mulai Sekarang"
}
+26 -5
View File
@@ -1,10 +1,31 @@
// ─── Optional Telemetry Bootstrap ───────────────────────────────
// Landing app uses minimal telemetry. Remove this block entirely
// if you want zero observability overhead.
import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-landing',
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
environment: import.meta.env.VITE_ENV || 'development',
// No otlpTraceUrl — only Faro collector, minimal overhead
});
// ─── Application Bootstrap ──────────────────────────────────────
import './main.css'; import './main.css';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import App from './app'; import App from './app';
createRoot(document.getElementById('app')!).render( async function bootstrap() {
<StrictMode> await setupI18n();
<App />
</StrictMode>, createRoot(document.getElementById('app')!).render(
); <StrictMode>
<App />
</StrictMode>,
);
}
bootstrap();
@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { useTranslation, changeLanguage, i18n } from '@repo/core-i18n';
// Decentralized locale imports
import homeId from '../locales/id/home.json';
import homeEn from '../locales/en/home.json';
export default function I18nLandingSample() {
const { t } = useTranslation(['common', 'home']);
// 1. Lazy-load the 'home' namespace when the module mounts
useEffect(() => {
i18n.addResourceBundle('id', 'home', homeId, true, false);
i18n.addResourceBundle('en', 'home', homeEn, true, false);
}, []);
// 2. Change language without syncCallback to prove decoupling
const setLanguage = (lng: string) => {
// We intentionally omit the second argument (syncCallback)
// because the landing page is public and doesn't need backend syncing.
changeLanguage(lng).catch(console.error);
};
return (
<div style={{ padding: 40, textAlign: 'center', backgroundColor: '#f8fafc', color: '#0f172a' }}>
<h1 style={{ fontSize: 36, fontWeight: 'bold', marginBottom: 16 }}>
{t('home:welcome')}
</h1>
<div style={{ marginBottom: 32 }}>
<button
onClick={() => setLanguage('id')}
style={{ padding: '8px 16px', marginRight: 8, cursor: 'pointer', borderRadius: 4, background: '#3b82f6', color: 'white', border: 'none' }}
>
Bahasa Indonesia
</button>
<button
onClick={() => setLanguage('en')}
style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: 4, background: '#3b82f6', color: 'white', border: 'none' }}
>
English
</button>
</div>
<button style={{ padding: '16px 32px', fontSize: 18, fontWeight: 'bold', cursor: 'pointer', borderRadius: 8, background: '#10b981', color: 'white', border: 'none' }}>
{t('home:cta')}
</button>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import 'react-i18next';
// Import the core types so we don't break the common namespace
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
import homeEn from '../locales/en/home.json';
// Combine core resources with app-specific decentralized resources
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof coreResources['en'] & {
home: typeof homeEn;
};
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+5
View File
@@ -13,12 +13,17 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"@repo/utils": "workspace:*", "@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"i18next": "^24.2.2",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
@@ -0,0 +1,22 @@
import BookingSample from './features/booking/presentation/BookingSample';
import StorageSample from './features/storage/presentation/StorageSample';
import I18nSample from './features/i18n/presentation/I18nSample';
export default function ExamplePage() {
return (
<div className="bg-amber-200">
example
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<BookingSample />
</div>
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<StorageSample />
</div>
<div className="p-8 bg-slate-900">
<I18nSample />
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../lib/api-client';
// ─── Domain Entity ──────────────────────────────────────────────
/**
* Booking domain entity.
*
* In a real module, this would be defined in the domain layer
* (e.g., `features/booking/domain/entities.ts`) and imported here.
*/
export interface BookingEntity extends BaseEntity {
bookingCode: string;
customerName: string;
checkInDate: string;
checkOutDate: string;
status: 'pending' | 'confirmed' | 'cancelled';
totalAmount: number;
}
// ─── Data Services Instance ─────────────────────────────────────
/**
* Booking data services wired to the enterprise `apiClient`.
*
* All requests flow through the full interceptor chain:
* Faro tracing Bearer token injection ApiError normalization.
*
* @example
* ```ts
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
* const { data: booking } = await bookingServices.getOne('42');
* await bookingServices.confirmProcessTransaction('42');
* ```
*/
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
apiClient,
{
apiUrl: '/bookings',
moduleKey: 'BOOKING',
},
);
@@ -0,0 +1,92 @@
import { useState } from 'react';
import { bookingServices } from '../data/booking.data-services';
import type { BookingEntity } from '../data/booking.data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { ApiError } from '@repo/core-api/errors';
/**
* Sample component demonstrating `@repo/core-api` integration
* with the advanced TelemetryContext escape hatch.
*
* Pipeline: Faro auto-instrumentation Bearer token GET /bookings
* + Custom span "booking.list.fetch" with enriched tags
*/
export default function BookingSample() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFetch = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
const response = await bookingServices.getMany<BookingEntity[]>({
params: { page: 1, limit: 20 },
// ── Telemetry Escape Hatch ──────────────────────────────
// This creates a custom OTel span named "booking.list.fetch",
// attaches business tags, and pushes a Faro event on success.
telemetryContext: {
customSpanName: 'booking.list.fetch',
tags: {
'feature': 'booking',
'ui.component': 'BookingSample',
'ui.action': 'list_fetch',
'page': 1,
},
pushEventOnSuccess: 'booking_list_loaded',
},
});
setResult(response);
console.log('[BookingSample] Response:', response);
} catch (err) {
if (err instanceof ApiError) {
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
console.error('[BookingSample] ApiError:', err.toJSON());
} else {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 24, fontFamily: 'monospace' }}>
<h2>🧪 Booking Data Services Integration Test</h2>
<p style={{ color: '#888', fontSize: 14 }}>
Pipeline: Faro + Custom Span &quot;booking.list.fetch&quot; Bearer Token GET /bookings
</p>
<button
onClick={handleFetch}
disabled={loading}
style={{
padding: '10px 20px',
fontSize: 16,
cursor: loading ? 'wait' : 'pointer',
background: loading ? '#555' : '#4f46e5',
color: '#fff',
border: 'none',
borderRadius: 6,
marginTop: 12,
}}
>
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
</button>
{error && (
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{result && (
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
);
}
@@ -0,0 +1,8 @@
{
"module_name": "Purchasing",
"select_date": "Select Date",
"header": {
"title": "Transaction List",
"subtitle": "Manage all your transactions here"
}
}
@@ -0,0 +1,8 @@
{
"module_name": "Pembelanjaan",
"select_date": "Pilih Tanggal",
"header": {
"title": "Daftar Transaksi",
"subtitle": "Kelola semua transaksi Anda di sini"
}
}
@@ -0,0 +1,329 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
import { demoIndexedDB } from '@repo/core-storage';
// Decentralized locale imports
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
// ─── Shared Styles ──────────────────────────────────────────────
const sectionStyle = {
marginTop: 24,
padding: 24,
border: '1px solid #334155',
borderRadius: 8,
background: '#0f172a',
};
const btnStyle = (color: string) => ({
padding: '8px 16px',
fontSize: 14,
fontWeight: 600 as const,
cursor: 'pointer' as const,
background: color,
color: '#fff',
border: 'none',
borderRadius: 6,
marginRight: 8,
});
// ─── Component ──────────────────────────────────────────────────
export default function I18nSample() {
const { t } = useTranslation(['common', 'booking']);
const [syncStatus, setSyncStatus] = useState<string>('');
const [activeTenant, setActiveTenant] = useState<string>('default');
const [isFetchingConfig, setIsFetchingConfig] = useState(false);
// ─── Admin Panel State ──────────────────────────────────────────
const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
const MOCK_DB_KEY = 'mock_db_company_a';
const loadDbPayload = useCallback(async () => {
try {
const data = await demoIndexedDB.getItem<any>(MOCK_DB_KEY);
setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
setAdminHeaderTitle(data?.overrides?.header?.title || 'Daftar Pengeluaran');
setAdminModuleName(data?.overrides?.module_name || 'PENGELUARAN');
} catch (e) {
setDbPayloadStr('Error reading DB');
}
}, []);
useEffect(() => {
loadDbPayload();
}, [loadDbPayload]);
const handleAdminSave = async () => {
const payload = {
namespace: 'booking',
overrides: {
module_name: adminModuleName,
header: { title: adminHeaderTitle },
},
};
await demoIndexedDB.setItem(MOCK_DB_KEY, payload);
setSyncStatus('✅ Saved tenant config to IndexedDB!');
await loadDbPayload();
};
// ─── Mock API ───────────────────────────────────────────────────
const mockFetchTenantConfig = async (companyId: string): Promise<any> => {
if (companyId === 'company-a') {
const data = await demoIndexedDB.getItem<any>(MOCK_DB_KEY);
if (!data) {
throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
}
return data;
} else if (companyId === 'company-b') {
// Hardcoded fallback for B
return {
namespace: 'booking',
overrides: {
module_name: 'PROCUREMENT (B)',
header: { title: 'Procurement List (B)' },
},
};
}
throw new Error('Unknown company');
};
// 1. Lazy-load the 'booking' namespace when the module mounts
useEffect(() => {
// Check if it's already loaded to prevent duplicate work, but for safety:
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
}, []);
// ─── Section A: Language Switcher ──────────────────────────────
const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
setSyncStatus('Syncing with backend...');
try {
await changeLanguage(newLng, async (lng, _prevLng) => {
// Mock API Call
await new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error('Mock API 500: Failed to save preference'));
} else {
resolve(true);
}
}, 1000);
});
// If success
setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
});
} catch (error) {
setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
}
};
// ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
const handleSimulateLogin = async (companyId: string) => {
setIsFetchingConfig(true);
setActiveTenant(companyId);
try {
// 1. App successfully authenticates and fetches config
const config = await mockFetchTenantConfig(companyId);
// 2. Inject the deep-merge payload returned from the server
// In a real app, you might apply this to the current active language or all languages.
applyTenantOverrides(config.namespace, config.overrides, 'id');
applyTenantOverrides(config.namespace, config.overrides, 'en');
} catch (err) {
console.error('Failed to fetch config', err);
} finally {
setIsFetchingConfig(false);
}
};
const resetTenant = () => {
// To reset, we just reload the original bundles
i18n.addResourceBundle('id', 'booking', bookingId, true, true);
i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
setActiveTenant('default');
};
return (
<div style={{ fontFamily: 'sans-serif', color: '#f8fafc' }}>
<h2 style={{ fontSize: 24, fontWeight: 'bold' }}>🌐 Enterprise i18n Demo</h2>
<p style={{ color: '#94a3b8' }}>
Current Active Language: <strong style={{ color: '#38bdf8' }}>{i18n.language}</strong>
</p>
{/* ─── Admin Panel ──────────────────────────────────────────── */}
<div style={sectionStyle}>
<h3 style={{ fontSize: 18, marginBottom: 16, color: '#fbbf24' }}>Admin Panel (Company A Config)</h3>
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
<label style={{ fontSize: 14 }}>
<span style={{ display: 'inline-block', width: 120 }}>Module Name:</span>
<input
type="text"
value={adminModuleName}
onChange={(e) => setAdminModuleName(e.target.value)}
style={{
padding: 6,
borderRadius: 4,
background: '#1e293b',
border: '1px solid #475569',
color: '#fff',
width: 250,
}}
/>
</label>
<label style={{ fontSize: 14 }}>
<span style={{ display: 'inline-block', width: 120 }}>Header Title:</span>
<input
type="text"
value={adminHeaderTitle}
onChange={(e) => setAdminHeaderTitle(e.target.value)}
style={{
padding: 6,
borderRadius: 4,
background: '#1e293b',
border: '1px solid #475569',
color: '#fff',
width: 250,
}}
/>
</label>
</div>
<button onClick={handleAdminSave} style={btnStyle('#d97706')}>
Save to Database (IndexedDB)
</button>
<div style={{ marginTop: 16, padding: 12, background: '#1e293b', borderRadius: 6 }}>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>Raw JSON in DB:</div>
<pre style={{ margin: 0, fontSize: 12, color: '#a7f3d0' }}>
<code>{dbPayloadStr}</code>
</pre>
</div>
</div>
{/* ─── Section A ────────────────────────────────────────────── */}
<div style={sectionStyle}>
<h3 style={{ fontSize: 18, marginBottom: 16 }}>A. Language Switcher & Backend Sync</h3>
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
Change the language. The callback simulates a 1-second backend API request.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => handleLanguageChange('id')} style={btnStyle('#0284c7')}>
ID (Lokal & Sync)
</button>
<button onClick={() => handleLanguageChange('en')} style={btnStyle('#0284c7')}>
EN (Lokal & Sync)
</button>
<button onClick={() => handleLanguageChange('en', true)} style={btnStyle('#dc2626')}>
Force Error (Test Rollback)
</button>
</div>
{syncStatus && (
<div
style={{
marginTop: 16,
padding: 12,
background: '#1e293b',
borderRadius: 6,
fontSize: 14,
}}
>
{syncStatus}
</div>
)}
</div>
{/* ─── Section B ────────────────────────────────────────────── */}
<div style={sectionStyle}>
<h3 style={{ fontSize: 18, marginBottom: 16 }}>B. Dynamic Tenant Overrides (End-to-End)</h3>
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the
deep-merge override.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
<button onClick={resetTenant} style={btnStyle(activeTenant === 'default' ? '#16a34a' : '#475569')}>
Default Company
</button>
<button
onClick={() => handleSimulateLogin('company-a')}
style={btnStyle(activeTenant === 'company-a' ? '#16a34a' : '#475569')}
disabled={isFetchingConfig}
>
Simulate Login as Company A
</button>
<button
onClick={() => handleSimulateLogin('company-b')}
style={btnStyle(activeTenant === 'company-b' ? '#16a34a' : '#475569')}
disabled={isFetchingConfig}
>
Simulate Login as Company B
</button>
</div>
{isFetchingConfig && (
<div style={{ marginBottom: 16, color: '#fbbf24', fontSize: 14 }}> Fetching tenant config...</div>
)}
{/* Display the localized strings */}
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8 }}>
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result:</h4>
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
<tbody>
<tr style={{ borderBottom: '1px solid #334155' }}>
<th style={{ padding: 8, color: '#94a3b8' }}>Key</th>
<th style={{ padding: 8, color: '#94a3b8' }}>Value</th>
</tr>
{/* Type-safe keys from the common and booking namespaces */}
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}>
<code>booking:module_name</code>
</td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:module_name')}</td>
</tr>
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}>
<code>booking:header.title</code>
</td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.title')}</td>
</tr>
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}>
<code>booking:header.subtitle</code>
</td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.subtitle')}</td>
</tr>
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}>
<code>booking:select_date</code>
</td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:select_date')}</td>
</tr>
<tr>
<td style={{ padding: 8 }}>
<code>common:save</code>
</td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('common:save')}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,275 @@
import { useState, useCallback } from 'react';
import { demoSecureStorage, demoIndexedDB, StorageKey } from '@repo/core-storage';
// ─── Demo Data ──────────────────────────────────────────────────
interface DemoUser {
id: number;
user: string;
role: string;
}
interface DemoDraft {
id: number;
type: string;
content: string;
}
const DEMO_USER: DemoUser = { id: 1, user: 'Firman', role: 'admin' };
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
const LS_KEY = StorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
const IDB_KEY = 'offline_draft'; // Plain key for IndexedDB demo
// ─── Shared Styles ──────────────────────────────────────────────
const btnStyle = (color: string) => ({
padding: '8px 16px',
fontSize: 14,
fontWeight: 600 as const,
cursor: 'pointer' as const,
background: color,
color: '#fff',
border: 'none',
borderRadius: 6,
});
const preStyle = {
marginTop: 16,
background: '#1e1e2e',
color: '#a6e3a1',
padding: 16,
borderRadius: 8,
minHeight: 60,
overflow: 'auto' as const,
fontSize: 13,
};
const logContainerStyle = {
background: '#0f0f17',
color: '#94a3b8',
padding: 12,
borderRadius: 8,
maxHeight: 200,
overflow: 'auto' as const,
fontSize: 12,
};
// ─── Reusable CRUD Button Row ───────────────────────────────────
interface CRUDAction {
label: string;
handler: () => void;
color: string;
}
function CRUDButtons({ actions }: { actions: CRUDAction[] }) {
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{actions.map(({ label, handler, color }) => (
<button key={label} onClick={handler} style={btnStyle(color)}>
{label}
</button>
))}
</div>
);
}
// ─── Component ──────────────────────────────────────────────────
/**
* Interactive demo for `@repo/core-storage`.
*
* Demonstrates the full CRUD lifecycle for BOTH storage backends:
* - **localStorage** (encrypted via AES for sensitive keys)
* - **IndexedDB** (Promise-wrapped, suitable for large payloads)
*
* Open the browser's DevTools:
* - **Application Local Storage** to see AES-encrypted payloads
* - **Application IndexedDB app_db kv_store** to see IDB entries
*/
export default function StorageSample() {
const [lsResult, setLsResult] = useState<string>('(no data read yet)');
const [idbResult, setIdbResult] = useState<string>('(no data read yet)');
const [log, setLog] = useState<string[]>([]);
const pushLog = useCallback((msg: string) => {
setLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
}, []);
// ═══════════════════════════════════════════════════════════════
// ── localStorage CRUD ─────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
const lsCreate = useCallback(async () => {
await demoSecureStorage.setItem(LS_KEY, DEMO_USER);
pushLog(`[LS] CREATE → Stored encrypted: ${JSON.stringify(DEMO_USER)}`);
}, [pushLog]);
const lsRead = useCallback(async () => {
const result = await demoSecureStorage.getItem<DemoUser>(LS_KEY);
if (result) {
setLsResult(JSON.stringify(result, null, 2));
pushLog(`[LS] READ → Decrypted: ${JSON.stringify(result)}`);
} else {
setLsResult('(null — no data found)');
pushLog('[LS] READ → null (key does not exist)');
}
}, [pushLog]);
const lsUpdate = useCallback(async () => {
const existing = await demoSecureStorage.getItem<DemoUser>(LS_KEY);
if (!existing) {
pushLog('[LS] UPDATE → Failed: key does not exist. Create first.');
return;
}
const updated: DemoUser = { ...existing, role: 'superadmin', id: existing.id + 1 };
await demoSecureStorage.setItem(LS_KEY, updated);
pushLog(`[LS] UPDATE → Re-encrypted: ${JSON.stringify(updated)}`);
}, [pushLog]);
const lsDelete = useCallback(async () => {
await demoSecureStorage.removeItem(LS_KEY);
setLsResult('(deleted)');
pushLog(`[LS] DELETE → Removed key "${LS_KEY}"`);
}, [pushLog]);
const lsClear = useCallback(async () => {
await demoSecureStorage.clear();
setLsResult('(cleared)');
pushLog('[LS] CLEAR → All localStorage keys removed');
}, [pushLog]);
// ═══════════════════════════════════════════════════════════════
// ── IndexedDB CRUD ────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
const idbCreate = useCallback(async () => {
try {
await demoIndexedDB.setItem(IDB_KEY, DEMO_DRAFT);
pushLog(`[IDB] CREATE → Stored: ${JSON.stringify(DEMO_DRAFT)}`);
} catch (err) {
pushLog(`[IDB] CREATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbRead = useCallback(async () => {
try {
const result = await demoIndexedDB.getItem<DemoDraft>(IDB_KEY);
if (result) {
setIdbResult(JSON.stringify(result, null, 2));
pushLog(`[IDB] READ → Retrieved: ${JSON.stringify(result)}`);
} else {
setIdbResult('(null — no data found)');
pushLog('[IDB] READ → null (key does not exist)');
}
} catch (err) {
pushLog(`[IDB] READ → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbUpdate = useCallback(async () => {
try {
const existing = await demoIndexedDB.getItem<DemoDraft>(IDB_KEY);
if (!existing) {
pushLog('[IDB] UPDATE → Failed: key does not exist. Create first.');
return;
}
const updated: DemoDraft = {
...existing,
id: existing.id + 1,
content: `Updated at ${new Date().toLocaleTimeString()}`,
};
await demoIndexedDB.setItem(IDB_KEY, updated);
pushLog(`[IDB] UPDATE → Persisted: ${JSON.stringify(updated)}`);
} catch (err) {
pushLog(`[IDB] UPDATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbDelete = useCallback(async () => {
try {
await demoIndexedDB.removeItem(IDB_KEY);
setIdbResult('(deleted)');
pushLog(`[IDB] DELETE → Removed key "${IDB_KEY}"`);
} catch (err) {
pushLog(`[IDB] DELETE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbClear = useCallback(async () => {
try {
await demoIndexedDB.clear();
setIdbResult('(cleared)');
pushLog('[IDB] CLEAR → All IndexedDB entries removed');
} catch (err) {
pushLog(`[IDB] CLEAR → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
// ═══════════════════════════════════════════════════════════════
// ── Render ────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
return (
<div style={{ padding: 24, fontFamily: 'monospace' }}>
<h2>🔐 @repo/core-storage Dual Backend CRUD Demo</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 16 }}>
{/* ── Left: localStorage ─────────────────────────────────── */}
<div>
<h3 style={{ color: '#22c55e' }}>📦 localStorage (AES Encrypted)</h3>
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
Key: <code>{LS_KEY}</code> stored encrypted at rest
<br />
Verify: <strong>DevTools Application Local Storage</strong>
</p>
<CRUDButtons
actions={[
{ label: ' Create', handler: lsCreate, color: '#22c55e' },
{ label: '📖 Read', handler: lsRead, color: '#3b82f6' },
{ label: '✏️ Update', handler: lsUpdate, color: '#f59e0b' },
{ label: '🗑️ Delete', handler: lsDelete, color: '#ef4444' },
{ label: '💣 Clear', handler: lsClear, color: '#6b7280' },
]}
/>
<pre style={preStyle}>{lsResult}</pre>
</div>
{/* ── Right: IndexedDB ───────────────────────────────────── */}
<div>
<h3 style={{ color: '#8b5cf6' }}>🗃 IndexedDB (app_db / kv_store)</h3>
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
Key: <code>{IDB_KEY}</code> plain JSON (not in ENCRYPTED_KEYS)
<br />
Verify: <strong>DevTools Application IndexedDB app_db</strong>
</p>
<CRUDButtons
actions={[
{ label: ' Create', handler: idbCreate, color: '#8b5cf6' },
{ label: '📖 Read', handler: idbRead, color: '#06b6d4' },
{ label: '✏️ Update', handler: idbUpdate, color: '#f59e0b' },
{ label: '🗑️ Delete', handler: idbDelete, color: '#ef4444' },
{ label: '💣 Clear', handler: idbClear, color: '#6b7280' },
]}
/>
<pre style={preStyle}>{idbResult}</pre>
</div>
</div>
{/* ── Shared Action Log ────────────────────────────────────── */}
<h3 style={{ marginTop: 24 }}>📋 Action Log</h3>
<div style={logContainerStyle}>
{log.length === 0 ? (
<span style={{ color: '#475569' }}>(no actions yet)</span>
) : (
log.map((entry, i) => <div key={i}>{entry}</div>)
)}
</div>
</div>
);
}
+12 -1
View File
@@ -19,7 +19,8 @@ import {
Badge, Badge,
Divider, Divider,
} from '@repo/ui/components'; } from '@repo/ui/components';
import PrinterList from './printer-list' import PrinterList from './printer-list';
import ExamplePage from './example/example.page';
interface ShowcaseViewProps { interface ShowcaseViewProps {
colorScheme: ColorSchemeType; colorScheme: ColorSchemeType;
@@ -208,6 +209,16 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
<Card withBorder shadow="sm" radius="md" p="md"> <Card withBorder shadow="sm" radius="md" p="md">
<PrinterList /> <PrinterList />
</Card> </Card>
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">
Nested Showcase Example
</Title>
<Text>
This is an example of a nested showcase component. You can create multiple layers of showcases to organize
features by domain or complexity.
</Text>
<ExamplePage />
</Card>
</Stack> </Stack>
</Container> </Container>
); );
+42
View File
@@ -0,0 +1,42 @@
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
/**
* Enterprise HTTP client for `apps/web`.
*
* - Full Faro observability via the shared `faroAdapter`
* - Automatic Bearer token injection from localStorage
* - 401 redirect to `/auth/login`
* - Supports per-request `telemetryContext` for custom spans/tags
*
* All interceptors (auth, observability, error normalization)
* are baked into this instance. Import this singleton throughout
* the web application never create raw axios instances.
*/
export const apiClient = createHttpClient(
{
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
timeout: 15000,
observability: faroAdapter,
},
{
// ── Auth Interceptor ──────────────────────────────────────────
onRequest: async (config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
// ── Error Interceptor ─────────────────────────────────────────
onResponseError: async (error) => {
if (error.response?.status === 401) {
// Clear stale token and redirect to login
localStorage.removeItem('access_token');
window.location.href = '/auth/login';
}
throw error;
},
},
);
+27 -11
View File
@@ -1,17 +1,33 @@
// import { DateUtils } from '@repo/utils'; // ─── Observability Bootstrap (MUST be first) ────────────────────
// import { CurrencyUtils } from '@repo/utils'; // Initializes Grafana Faro + OTel auto-instrumentation before any
// React code or HTTP requests are executed.
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',
environment: import.meta.env.VITE_ENV || 'development',
});
// ─── Application Bootstrap ──────────────────────────────────────
import './main.css'; import './main.css';
import { lazy, StrictMode } from 'react'; import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
const App = lazy(() => import('./apps')); const App = lazy(() => import('./apps'));
// DateUtils.setGlobalConfig('Asia/Jakarta'); async function bootstrap() {
// DateUtils.setGlobalConfig('Asia/Makassar'); // Initialize i18next and load language from secureStorage
await setupI18n();
// CurrencyUtils.setGlobalPrefix('IDR '); createRoot(document.getElementById('app')!).render(
// CurrencyUtils.setGlobalDecimalSeparator(','); <StrictMode>
createRoot(document.getElementById('app')!).render( <App />
<StrictMode> </StrictMode>,
<App /> );
</StrictMode>, }
);
bootstrap();
+14
View File
@@ -0,0 +1,14 @@
import 'react-i18next';
// Import the core types so we don't break the common namespace
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
import bookingEn from '../apps/modules/example/features/i18n/locales/en/booking.json';
// Combine core resources with app-specific decentralized resources
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof coreResources['en'] & {
booking: typeof bookingEn;
};
}
}
+425
View File
@@ -0,0 +1,425 @@
# @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.
---
## 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)
---
## 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
```
### Data Flow
Every HTTP request flows through this 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()
```
> [!IMPORTANT]
> Observability adapter errors are **caught internally** via try-catch in the interceptor chain. An adapter crash will never swallow or replace the original API error — the UI always receives the correct rejection.
---
## HTTP Client
### `createHttpClient(config, hooks?)`
Creates an **isolated** Axios instance. Each app receives its own interceptor chain — no globals are shared or mutated.
```typescript
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
export const apiClient = createHttpClient(
{
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
timeout: 15000,
observability: faroAdapter,
},
{
onRequest: async (config) => {
const token = localStorage.getItem('access_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
onResponseError: async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
window.location.href = '/auth/login';
}
throw error;
},
},
);
```
### Configuration
| Property | Type | Default | Description |
|---|---|---|---|
| `baseURL` | `string` | *required* | Base URL for all requests |
| `timeout` | `number` | `15000` | Default request timeout (ms) |
| `defaultHeaders` | `Record<string, string>` | `{}` | Headers applied to every request |
| `observability` | `IObservabilityAdapter` | `noopAdapter` | Observability adapter (Faro or no-op) |
### Interceptor Hooks
| Hook | Signature | Purpose |
|---|---|---|
| `onRequest` | `(config) => config` | Inject auth tokens, tenant headers |
| `onResponse` | `(response) => response` | Transform response shapes |
| `onResponseError` | `(error) => never` | App-specific error handling (e.g., 401 redirect) |
---
## Observability
### Strategy: Opt-In Custom Spans + Faro/Loki Baseline
The observability layer operates in two complementary modes:
| Mode | Activation | What it does |
|---|---|---|
| **Baseline** (always on) | Automatic | Pushes structured logs to Faro/Loki on every request with `module.key`, `module.action`, HTTP method, and URL |
| **Custom Span** (opt-in) | Via `telemetryContext.customSpanName` | Creates an explicit OTel span with custom tags, visible in Grafana Tempo |
> [!NOTE]
> `trace.getActiveSpan()` returns `undefined` inside Axios interceptors due to browser XHR/Fetch lifecycle race conditions with Faro's `TracingInstrumentation`. The adapter does **not** attempt to enrich auto-instrumented spans. HTTP span capture is handled entirely by `TracingInstrumentation` auto-instrumentation.
### Initialization
Call `initTelemetry()` **once** at the top of your app's entry point, before any React code:
```typescript
import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
appName: 'fe-monorepo-web',
appVersion: '1.0.0',
telemetryUrl: 'https://telemetry.eigen.co.id/collect',
environment: 'production',
// Optional: direct OTLP export to Grafana Tempo
otlpTraceUrl: 'https://telemetry.eigen.co.id/v1/traces',
});
```
### `TelemetryConfig`
| Property | Type | Required | Description |
|---|---|---|---|
| `appName` | `string` | ✅ | Application name for Faro + OTel resource attributes |
| `appVersion` | `string` | ✅ | SemVer version |
| `telemetryUrl` | `string` | ✅ | Grafana Faro collector URL |
| `environment` | `string` | ✅ | Deployment environment (`production`, `staging`, `development`) |
| `otlpTraceUrl` | `string` | — | Separate OTLP trace endpoint for direct Tempo ingestion |
| `propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | — | CORS patterns for W3C trace context propagation (default: `[/.*/]`) |
### Audit Headers
Every request dispatched through `BaseRemoteDataServices` automatically attaches two business audit headers:
| Header | Source | Purpose |
|---|---|---|
| `ex-module-key` | `DataServicesConfig.moduleKey` | Identifies the business module (e.g., `BOOKING`) |
| `ex-module-action` | `RequestDescriptor.action` | Identifies the operation (e.g., `READ`, `CREATE`) |
These headers are extracted by the `faroAdapter` and included in all Faro `pushLog`, `pushError`, and `pushEvent` calls as top-level context — making them directly queryable in **LogQL (Loki)**.
### Span Safety Guarantees
| Guarantee | Mechanism |
|---|---|
| **No span leaks** | `safeEndSpan()` always closes the span and detaches the reference from config |
| **No double-close on retry** | Span reference is deleted from config after `span.end()` |
| **No error swallowing** | All adapter calls are wrapped in try-catch in `create-http-client.ts` |
| **No crash on timeout** | `null`/`undefined` config guards on all `error.config` access |
---
## Data Services
### `CommonRemoteDataServices<E>`
A concrete, ready-to-use data services class that provides full CRUD and lifecycle operations. Extends `BaseRemoteDataServices<E>`.
```typescript
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '@/lib/api-client';
interface BookingEntity extends BaseEntity {
bookingCode: string;
customerName: string;
status: 'pending' | 'confirmed' | 'cancelled';
}
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
apiClient,
{
apiUrl: '/bookings',
moduleKey: 'BOOKING',
},
);
```
### Available Operations
| Method | HTTP | URL Template | Description |
|---|---|---|---|
| `getMany(config?)` | GET | `/bookings` | Fetch paginated list |
| `getOne(id, config?)` | GET | `/bookings/:id` | Fetch single entity |
| `create(data, config?)` | POST | `/bookings` | Create new entity |
| `edit(id, data, config?)` | PUT | `/bookings/:id` | Update entity |
| `delete(id, config?)` | DELETE | `/bookings/:id` | Delete entity |
| `batchDelete(ids, config?)` | DELETE | `/bookings/batch` | Delete multiple |
| `activate(id)` | PATCH | `/bookings/:id/activate` | Activate entity |
| `deactivate(id)` | PATCH | `/bookings/:id/deactivate` | Deactivate entity |
| `confirmProcessData(id)` | PATCH | `/bookings/:id/confirm-process-data` | Confirm data processing |
| `confirmProcessTransaction(id)` | PATCH | `/bookings/:id/confirm-process-transaction` | Confirm transaction |
| `cancelProcessTransaction(id)` | PATCH | `/bookings/:id/cancel-process-transaction` | Cancel transaction |
| `rollbackProcessTransaction(id)` | PATCH | `/bookings/:id/rollback-process-transaction` | Rollback transaction |
| `holdProcessTransaction(id)` | PATCH | `/bookings/:id/hold-process-transaction` | Hold transaction |
All batch variants (`batchActivate`, `batchDeactivate`, etc.) are also available.
### Escape Hatch: `customRequest<T>(config)`
For non-standard endpoints that don't fit the CRUD pattern:
```typescript
const taxResult = await bookingServices.customRequest<TaxCalculation>({
url: '/bookings/42/calculate-tax',
method: 'POST',
data: { items: [...] },
});
```
---
## Application Setup Guide
### 1. Initialize Telemetry (Entry Point)
```typescript
// apps/web/src/main.tsx — MUST be the first import
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',
environment: import.meta.env.VITE_ENV || 'development',
});
// ... rest of React bootstrap
```
### 2. Create the HTTP Client
```typescript
// apps/web/src/lib/api-client.ts
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
export const apiClient = createHttpClient({
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
timeout: 15000,
observability: faroAdapter,
});
```
### 3. Create a Data Service
```typescript
// features/booking/data/booking.data-services.ts
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '@/lib/api-client';
export interface BookingEntity extends BaseEntity {
bookingCode: string;
customerName: string;
status: 'pending' | 'confirmed' | 'cancelled';
totalAmount: number;
}
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
apiClient,
{ apiUrl: '/bookings', moduleKey: 'BOOKING' },
);
```
### 4. Consume in a React Component
```tsx
import { useState } from 'react';
import { bookingServices } from '../data/booking.data-services';
import type { BookingEntity } from '../data/booking.data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { ApiError } from '@repo/core-api/errors';
export default function BookingSample() {
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFetch = async () => {
try {
const response = await bookingServices.getMany<BookingEntity[]>({
params: { page: 1, limit: 20 },
// Optional: Per-request telemetry escape hatch
telemetryContext: {
customSpanName: 'booking.list.fetch',
tags: { feature: 'booking', page: 1 },
pushEventOnSuccess: 'booking_list_loaded',
},
});
setResult(response);
} catch (err) {
if (err instanceof ApiError) {
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
}
}
};
return <button onClick={handleFetch}>Fetch Bookings</button>;
}
```
---
## Per-Request Telemetry (Escape Hatch)
### `TelemetryContext`
Attach to any request via the `telemetryContext` property to push custom spans and business events:
```typescript
interface TelemetryContext {
/** Creates a custom OTel span wrapping this request (visible in Grafana Tempo). */
customSpanName?: string;
/** Custom tags enriching the span and Faro logs (prefixed with `custom.` on spans). */
tags?: Record<string, string | number | boolean>;
/** Pushes a named Faro event on success (visible in Grafana Faro dashboard). */
pushEventOnSuccess?: string;
}
```
### Precedence
`telemetryContext` can be provided at two levels. The top-level `ExecuteOptions.telemetryContext` takes precedence over `config.telemetryContext`:
```typescript
// Top-level (preferred)
await bookingServices.getMany({
telemetryContext: { customSpanName: 'booking.list.fetch' },
});
// Nested in config (also works)
await bookingServices.getMany({
params: { page: 1 },
telemetryContext: { customSpanName: 'booking.list.fetch' },
});
```
### What Happens at Each Stage
| Stage | Baseline (no telemetryContext) | With `customSpanName` |
|---|---|---|
| **Request Start** | Faro `pushLog` (DEBUG) with `module.key`, `module.action`, URL | + Creates OTel span with `http.method`, `http.url`, `custom.*` tags |
| **Request Success** | — | Closes span (OK). If `pushEventOnSuccess`, pushes Faro event |
| **Request Error** | Faro `pushError` + `pushLog` (ERROR) | + Closes span (ERROR), records exception |
---
## Error Handling
### `ApiError`
All non-2xx responses are normalized into structured `ApiError` instances:
```typescript
try {
await bookingServices.getOne('42');
} catch (err) {
if (err instanceof ApiError) {
err.code; // ApiErrorCode.NOT_FOUND
err.status; // 404
err.message; // "Booking not found"
err.data; // Raw server response body
err.toJSON(); // Serializable for logging
}
}
```
### Error Codes
| Code | HTTP Status | Description |
|---|---|---|
| `BAD_REQUEST` | 400 | Invalid request parameters |
| `UNAUTHORIZED` | 401 | Missing or expired token |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `TIMEOUT` | — | Request timed out (`ECONNABORTED`) |
| `CANCELLED` | — | Request was cancelled (`ERR_CANCELED`) |
| `NETWORK_ERROR` | — | No response received |
| `SERVER_ERROR` | 500+ | Internal server error |
---
## Package Exports
| Import Path | Contents |
|---|---|
| `@repo/core-api/http-client` | `createHttpClient`, `ApiResponse`, `TelemetryContext`, Axios type re-exports |
| `@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` |
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@repo/core-api",
"version": "0.0.0",
"type": "module",
"exports": {
"./http-client": "./src/http-client/index.ts",
"./observability": "./src/observability/index.ts",
"./observability/setup": "./src/observability/setup.ts",
"./data-services": "./src/data-services/index.ts",
"./errors": "./src/errors/index.ts"
},
"license": "MIT",
"scripts": {
"lint": "eslint \"**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@grafana/faro-react": "^2.1.0",
"@grafana/faro-web-sdk": "^2.1.0",
"@grafana/faro-web-tracing": "^2.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
"@opentelemetry/sdk-trace-web": "^2.2.0",
"axios": "^1.9.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"eslint": "^8.57.1",
"typescript": "5.5.4",
"vitest": "^4.0.17"
}
}
@@ -0,0 +1,263 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type { BaseEntity } from './types';
import { CommonRemoteDataServices } from './common-remote.data-services';
// ─── Mock AxiosInstance ─────────────────────────────────────────
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({
data: [{ id: '1', name: 'Test' }],
status: 200,
}),
// Satisfy the AxiosInstance shape (unused properties)
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
// ─── Test Entity ────────────────────────────────────────────────
interface TestEntity extends BaseEntity {
bookingCode: string;
customerName: string;
}
// ─── Tests ──────────────────────────────────────────────────────
describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
let mockClient: AxiosInstance;
let services: CommonRemoteDataServices<TestEntity>;
beforeEach(() => {
mockClient = createMockHttpClient();
services = new CommonRemoteDataServices<TestEntity>(mockClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
});
});
// ── URL Interpolation ─────────────────────────────────────────
describe('URL interpolation', () => {
it('getMany() uses the base URL without params', async () => {
await services.getMany();
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/bookings',
method: 'GET',
}),
);
});
it('getOne() replaces :id in the URL template', async () => {
await services.getOne('123');
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/bookings/123',
method: 'GET',
}),
);
});
it('getOne() encodes special characters in ID', async () => {
await services.getOne('hello world');
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/bookings/hello%20world',
}),
);
});
it('activate() resolves to /:id/active', async () => {
await services.activate('42');
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/bookings/42/active',
method: 'PATCH',
}),
);
});
it('confirmProcessTransaction() resolves to /:id/confirm-data', async () => {
await services.confirmProcessTransaction('99');
expect(mockClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/bookings/99/confirm-data',
method: 'PATCH',
}),
);
});
});
// ── Header Injection ──────────────────────────────────────────
describe('header injection', () => {
it('injects ex-module-key from moduleKey config', async () => {
await services.getMany();
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-key']).toBe('BOOKING');
});
it('injects ex-module-action from the descriptor action', async () => {
await services.getMany(); // VIEW action
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-action']).toBe('VIEW');
});
it('injects CREATE action for create()', async () => {
await services.create({ bookingCode: 'BK001', customerName: 'Test' } as Partial<TestEntity>);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-action']).toBe('CREATE');
});
it('injects EDIT action for edit()', async () => {
await services.edit('42', { customerName: 'Updated' } as Partial<TestEntity>);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-action']).toBe('EDIT');
});
it('injects DELETE action for delete()', async () => {
await services.delete('42');
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-action']).toBe('DELETE');
});
it('omits ex-module-key when moduleKey is not configured', async () => {
const noKeyServices = new CommonRemoteDataServices<TestEntity>(mockClient, {
apiUrl: '/items',
});
await noKeyServices.getMany();
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-key']).toBeUndefined();
// But action is always present
expect(requestArg.headers['ex-module-action']).toBe('VIEW');
});
it('preserves caller-provided headers alongside injected ones', async () => {
await services.getMany({
headers: { 'X-Custom-Header': 'custom-value' },
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-key']).toBe('BOOKING');
expect(requestArg.headers['ex-module-action']).toBe('VIEW');
expect(requestArg.headers['X-Custom-Header']).toBe('custom-value');
});
});
// ── Telemetry Context ─────────────────────────────────────────
describe('telemetry context passthrough', () => {
it('passes telemetryContext from getMany() config to the Axios request', async () => {
const telemetryContext = {
customSpanName: 'booking.list.fetch',
tags: { region: 'asia' },
pushEventOnSuccess: 'booking_list_loaded',
};
await services.getMany({ telemetryContext } as AxiosRequestConfig);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.telemetryContext).toEqual(telemetryContext);
});
it('passes telemetryContext via customRequest()', async () => {
const telemetryContext = {
customSpanName: 'custom.tax.calculate',
tags: { business: 'tax' },
};
await services.customRequest({
url: '/bookings/42/calculate-tax',
method: 'POST',
data: { items: [] },
telemetryContext,
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.telemetryContext).toEqual(telemetryContext);
});
it('customRequest() still injects ex-module-key header', async () => {
await services.customRequest({
url: '/bookings/42/calculate-tax',
method: 'POST',
});
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.headers['ex-module-key']).toBe('BOOKING');
});
});
// ── Response Shape ────────────────────────────────────────────
describe('response shape', () => {
it('returns { data, status } from the Axios response', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
status: 200,
});
const result = await services.getOne<TestEntity>('42');
expect(result).toEqual({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
status: 200,
});
});
it('create() passes data in the Axios request config', async () => {
const newBooking: Partial<TestEntity> = {
bookingCode: 'BK001',
customerName: 'Bob',
};
await services.create(newBooking);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.method).toBe('POST');
expect(requestArg.url).toBe('/bookings');
expect(requestArg.data).toEqual(newBooking);
});
});
// ── Batch Operations ──────────────────────────────────────────
describe('batch operations', () => {
it('batchDelete() sends ids in data payload', async () => {
await services.batchDelete(['1', '2', '3']);
const requestArg = (mockClient.request as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(requestArg.url).toBe('/bookings/batch-delete');
expect(requestArg.data).toEqual({ ids: ['1', '2', '3'] });
});
});
});
@@ -0,0 +1,314 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
} from './types';
import type { ApiResponse } from '../http-client/types';
import { interpolateUrl } from './url-builder';
import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
/**
* Abstract base class for remote data services.
*
* Provides a generic `execute()` method that eliminates the 22
* near-identical methods from the legacy `BaseRemoteDataServices`.
* Each operation is reduced to a one-liner calling `execute()`
* with the appropriate descriptor.
*
* **Key architectural differences from legacy:**
* - Receives an **injected** AxiosInstance (no global axios import)
* - Returns `Promise<ApiResponse<T>>` (no callback-based onSuccess/onFailed)
* - All operations are fully typed end-to-end
* - Includes `customRequest<T>()` as an escape hatch for non-standard endpoints
*
* @typeParam E - The domain entity type (must extend BaseEntity)
*
* @example
* ```ts
* class BookingDataServices extends BaseRemoteDataServices<BookingEntity> {}
*
* const services = new BookingDataServices(apiClient, {
* apiUrl: '/bookings',
* moduleKey: 'BOOKING',
* });
*
* const { data, status } = await services.getOne<BookingEntity>('42');
* ```
*/
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity> {
/** The injected, isolated HTTP client instance. */
protected readonly httpClient: AxiosInstance;
/** Resolved URL map for all operations. */
protected readonly urls: ApiURLMap;
/** Resolved HTTP method map for all operations. */
protected readonly methods: RequestMethodMap;
/** Module key for the 'ex-module-key' audit header. */
protected readonly moduleKey: string | undefined;
constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
this.httpClient = httpClient;
this.moduleKey = config.moduleKey;
this.urls = {
...makeDefaultURLs(config.apiUrl ?? ''),
...(config.urls ?? {}),
};
this.methods = {
...DEFAULT_METHODS,
...(config.methods ?? {}),
};
}
// ─── Generic Executor ───────────────────────────────────────────
/**
* The single generic request executor.
*
* All standard operations delegate to this method with a
* pre-defined descriptor. This is the engine that replaces
* 22 near-identical legacy methods.
*
* @typeParam T - Expected response data type
* @param descriptor - Defines which URL, method, and action to use
* @param options - Dynamic URL params and additional Axios config
* @returns Typed API response with data and status
*/
protected async execute<T = unknown>(
descriptor: RequestDescriptor,
options?: ExecuteOptions,
): Promise<ApiResponse<T>> {
const { urlKey, methodKey, action } = descriptor;
const response = await this.httpClient.request<T>({
url: interpolateUrl(this.urls[urlKey], options?.variableURL),
method: this.methods[methodKey],
...(options?.config ?? {}),
headers: {
...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}),
'ex-module-action': action,
...(options?.config?.headers ?? {}),
},
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext // FIXME,
});
return { data: response.data, status: response.status };
}
// ─── Escape Hatch ───────────────────────────────────────────────
/**
* Execute a fully custom request that doesn't fit standard CRUD.
*
* Use this for non-standard endpoints like `/calculate-tax`,
* custom aggregations, or third-party integrations.
*
* The request still flows through the injected httpClient, so
* all interceptors (auth, observability, error handling) are
* preserved automatically.
*
* @typeParam T - Expected response data type
* @param config - Complete Axios request configuration
* @returns Typed API response with data and status
*
* @example
* ```ts
* const tax = await services.customRequest<TaxResult>({
* url: '/bookings/42/calculate-tax',
* method: 'POST',
* data: { items: [...] },
* });
* ```
*/
async customRequest<T = unknown>(
config: AxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.httpClient.request<T>({
...config,
headers: {
...(this.moduleKey ? { 'ex-module-key': this.moduleKey } : {}),
...(config.headers ?? {}),
},
});
return { data: response.data, status: response.status };
}
// ─── CRUD Operations ───────────────────────────────────────────
/** Fetch a paginated list of entities. */
getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getMany, { config });
}
/** Fetch a single entity by ID. */
getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
}
/** Create a new entity. */
create<T = E>(data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.create, {
config: { ...config, data },
});
}
/** Update an existing entity by ID. */
edit<T = E>(id: string, data: Partial<E>, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return this.execute<T>(DESCRIPTORS.edit, {
variableURL: { id },
config: { ...config, data },
});
}
/** Delete a single entity by ID. */
delete(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.delete, {
variableURL: { id },
config,
});
}
/** Delete multiple entities by IDs. */
batchDelete(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids } },
});
}
// ─── Activation Lifecycle ─────────────────────────────────────
/** Activate a single entity. */
activate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.activate, {
variableURL: { id },
config,
});
}
/** Activate multiple entities. */
batchActivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids } },
});
}
/** Deactivate a single entity. */
deactivate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.deactivate, {
variableURL: { id },
config,
});
}
/** Deactivate multiple entities. */
batchDeactivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids } },
});
}
// ─── Data Processing Lifecycle ────────────────────────────────
/** Confirm processing of a single data record. */
confirmProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessData, {
variableURL: { id },
config,
});
}
/** Confirm processing of multiple data records. */
batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessData, {
config: { ...config, data: { ids } },
});
}
/** Cancel processing of a single data record. */
cancelProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessData, {
variableURL: { id },
config,
});
}
/** Cancel processing of multiple data records. */
batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessData, {
config: { ...config, data: { ids } },
});
}
// ─── Transaction Lifecycle ────────────────────────────────────
/** Confirm a transaction. */
confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessTransaction, {
variableURL: { id },
config,
});
}
/** Confirm multiple transactions. */
batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Cancel a transaction. */
cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessTransaction, {
variableURL: { id },
config,
});
}
/** Cancel multiple transactions. */
batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Rollback a transaction. */
rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.rollbackProcessTransaction, {
variableURL: { id },
config,
});
}
/** Rollback multiple transactions. */
batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Hold a transaction. */
holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.holdProcessTransaction, {
variableURL: { id },
config,
});
}
/** Hold multiple transactions. */
batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldProcessTransaction, {
config: { ...config, data: { ids } },
});
}
}
@@ -0,0 +1,43 @@
import type { BaseEntity } from './types';
import { BaseRemoteDataServices } from './base-remote.data-services';
/**
* General-purpose remote data services.
*
* A concrete, non-abstract version of BaseRemoteDataServices that
* can be instantiated directly for standard CRUD modules that don't
* need additional custom methods.
*
* For modules requiring domain-specific operations beyond standard
* CRUD + lifecycle, extend BaseRemoteDataServices instead and add
* custom methods using `this.execute()` or `this.customRequest()`.
*
* @typeParam E - The domain entity type
*
* @example
* ```ts
* // Direct instantiation for standard modules
* const bookingServices = new CommonRemoteDataServices<BookingEntity>(
* apiClient,
* { apiUrl: '/bookings', moduleKey: 'BOOKING' },
* );
*
* const { data } = await bookingServices.getMany();
* ```
*
* @example
* ```ts
* // For modules needing custom operations, extend the base:
* class InvoiceDataServices extends BaseRemoteDataServices<InvoiceEntity> {
* async calculateTax(invoiceId: string) {
* return this.customRequest<TaxResult>({
* url: `/invoices/${invoiceId}/calculate-tax`,
* method: 'POST',
* });
* }
* }
* ```
*/
export class CommonRemoteDataServices<
E extends BaseEntity = BaseEntity,
> extends BaseRemoteDataServices<E> {}
@@ -0,0 +1,126 @@
import type { RequestMethodMap, RequestDescriptor, ApiURLMap } from './types';
// ─── Request Actions ────────────────────────────────────────────
/**
* Constants for the 'ex-module-action' header.
* Maps to backend permission/audit checks.
*/
export const REQUEST_ACTION = {
VIEW: 'VIEW',
CREATE: 'CREATE',
EDIT: 'EDIT',
DELETE: 'DELETE',
CONFIRM_DATA: 'CONFIRM_DATA',
CANCEL_DATA: 'CANCEL_DATA',
CONFIRM_PROCESS_TRANSACTION: 'CONFIRM_PROCESS_TRANSACTION',
CANCEL_PROCESS_TRANSACTION: 'CANCEL_PROCESS_TRANSACTION',
} as const;
// ─── Default HTTP Methods ───────────────────────────────────────
/**
* Sensible REST defaults for all operations.
* Can be overridden per data-services instance.
*/
export const DEFAULT_METHODS: RequestMethodMap = {
getManyMethod: 'GET',
getOneMethod: 'GET',
createMethod: 'POST',
editMethod: 'PUT',
deleteMethod: 'DELETE',
batchDeleteMethod: 'POST',
activateMethod: 'PATCH',
batchActivateMethod: 'POST',
deactivateMethod: 'PATCH',
batchDeactivateMethod: 'POST',
confirmProcessDataMethod: 'PATCH',
batchConfirmProcessDataMethod: 'POST',
cancelProcessDataMethod: 'PATCH',
batchCancelProcessDataMethod: 'POST',
confirmProcessTransactionMethod: 'PATCH',
batchConfirmProcessTransactionMethod: 'POST',
cancelProcessTransactionMethod: 'PATCH',
batchCancelProcessTransactionMethod: 'POST',
rollbackProcessTransactionMethod: 'PATCH',
batchRollbackProcessTransactionMethod: 'POST',
holdProcessTransactionMethod: 'PATCH',
batchHoldProcessTransactionMethod: 'POST',
};
// ─── Operation Descriptors ──────────────────────────────────────
/**
* Pre-defined descriptors for all standard operations.
* Each descriptor maps an operation to its URL key, method key,
* and action header eliminating 22 boilerplate methods.
*/
export const DESCRIPTORS = {
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchDeactivate: { urlKey: 'batchDeactivateUrl', methodKey: 'batchDeactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
confirmProcessData: { urlKey: 'confirmProcessDataUrl', methodKey: 'confirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchConfirmProcessData: { urlKey: 'batchConfirmProcessDataUrl', methodKey: 'batchConfirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
cancelProcessData: { urlKey: 'cancelProcessDataUrl', methodKey: 'cancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
batchCancelProcessData: { urlKey: 'batchCancelProcessDataUrl', methodKey: 'batchCancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
confirmProcessTransaction: { urlKey: 'confirmProcessTransactionUrl', methodKey: 'confirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchConfirmProcessTransaction: { urlKey: 'batchConfirmProcessTransactionUrl', methodKey: 'batchConfirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
cancelProcessTransaction: { urlKey: 'cancelProcessTransactionUrl', methodKey: 'cancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
batchCancelProcessTransaction: { urlKey: 'batchCancelProcessTransactionUrl', methodKey: 'batchCancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
rollbackProcessTransaction: { urlKey: 'rollbackProcessTransactionUrl', methodKey: 'rollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchRollbackProcessTransaction: { urlKey: 'batchRollbackProcessTransactionUrl', methodKey: 'batchRollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
holdProcessTransaction: { urlKey: 'holdProcessTransactionUrl', methodKey: 'holdProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchHoldProcessTransaction: { urlKey: 'batchHoldProcessTransactionUrl', methodKey: 'batchHoldProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
} as const satisfies Record<string, RequestDescriptor>;
// ─── Default URL Factory ────────────────────────────────────────
/**
* Generates the complete API URL map from a base path.
*
* @param apiUrl - Base API path (e.g., '/bookings')
* @returns Full ApiURLMap with all CRUD + lifecycle URLs
*/
export function makeDefaultURLs(apiUrl: string): ApiURLMap {
return {
getManyUrl: `${apiUrl}`,
getOneUrl: `${apiUrl}/:id`,
createUrl: `${apiUrl}`,
editUrl: `${apiUrl}/:id`,
deleteUrl: `${apiUrl}/:id`,
batchDeleteUrl: `${apiUrl}/batch-delete`,
activateUrl: `${apiUrl}/:id/active`,
batchActivateUrl: `${apiUrl}/batch-active`,
deactivateUrl: `${apiUrl}/:id/inactive`,
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
confirmProcessDataUrl: `${apiUrl}/:id/confirm`,
batchConfirmProcessDataUrl: `${apiUrl}/batch-confirm`,
cancelProcessDataUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessDataUrl: `${apiUrl}/batch-cancel`,
confirmProcessTransactionUrl: `${apiUrl}/:id/confirm-data`,
batchConfirmProcessTransactionUrl: `${apiUrl}/batch-confirm-data`,
cancelProcessTransactionUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessTransactionUrl: `${apiUrl}/batch-cancel`,
rollbackProcessTransactionUrl: `${apiUrl}/:id/confirm-rollback`,
batchRollbackProcessTransactionUrl: `${apiUrl}/batch-confirm-rollback`,
holdProcessTransactionUrl: `${apiUrl}/:id/confirm-hold`,
batchHoldProcessTransactionUrl: `${apiUrl}/batch-confirm-hold`,
};
}
@@ -0,0 +1,19 @@
// ─── Classes ────────────────────────────────────────────────────
export { BaseRemoteDataServices } from './base-remote.data-services';
export { CommonRemoteDataServices } from './common-remote.data-services';
// ─── Utilities ──────────────────────────────────────────────────
export { interpolateUrl } from './url-builder';
// ─── Constants ──────────────────────────────────────────────────
export { REQUEST_ACTION, DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
// ─── Types ──────────────────────────────────────────────────────
export type {
BaseEntity,
ApiURLMap,
RequestMethodMap,
RequestDescriptor,
ExecuteOptions,
DataServicesConfig,
} from './types';
@@ -0,0 +1,125 @@
import type { AxiosRequestConfig } from 'axios';
import type { TelemetryContext } from '../http-client/types';
// ─── Base Entity ────────────────────────────────────────────────
/**
* Minimal entity contract. All domain entities must have
* an optional `id` field for CRUD operations.
*/
export interface BaseEntity {
id?: string;
}
// ─── API URL Map ────────────────────────────────────────────────
/**
* Complete URL map for all standard CRUD and lifecycle operations.
* Each key maps to a URL template string (e.g., '/bookings/:id').
*/
export interface ApiURLMap {
getManyUrl: string;
getOneUrl: string;
createUrl: string;
editUrl: string;
deleteUrl: string;
batchDeleteUrl: string;
activateUrl: string;
batchActivateUrl: string;
deactivateUrl: string;
batchDeactivateUrl: string;
confirmProcessDataUrl: string;
batchConfirmProcessDataUrl: string;
cancelProcessDataUrl: string;
batchCancelProcessDataUrl: string;
confirmProcessTransactionUrl: string;
batchConfirmProcessTransactionUrl: string;
cancelProcessTransactionUrl: string;
batchCancelProcessTransactionUrl: string;
rollbackProcessTransactionUrl: string;
batchRollbackProcessTransactionUrl: string;
holdProcessTransactionUrl: string;
batchHoldProcessTransactionUrl: string;
}
// ─── HTTP Method Map ────────────────────────────────────────────
/**
* HTTP method overrides for each operation.
* Defaults to sensible REST conventions (GET, POST, PUT, DELETE, PATCH).
*/
export interface RequestMethodMap {
getManyMethod: string;
getOneMethod: string;
createMethod: string;
editMethod: string;
deleteMethod: string;
batchDeleteMethod: string;
activateMethod: string;
batchActivateMethod: string;
deactivateMethod: string;
batchDeactivateMethod: string;
confirmProcessDataMethod: string;
batchConfirmProcessDataMethod: string;
cancelProcessDataMethod: string;
batchCancelProcessDataMethod: string;
confirmProcessTransactionMethod: string;
batchConfirmProcessTransactionMethod: string;
cancelProcessTransactionMethod: string;
batchCancelProcessTransactionMethod: string;
rollbackProcessTransactionMethod: string;
batchRollbackProcessTransactionMethod: string;
holdProcessTransactionMethod: string;
batchHoldProcessTransactionMethod: string;
}
// ─── Request Descriptor ─────────────────────────────────────────
/**
* Describes a single operation in terms of its URL, method, and
* action header. Used by the generic `execute()` method.
*/
export interface RequestDescriptor {
/** Key into the ApiURLMap. */
urlKey: keyof ApiURLMap;
/** Key into the RequestMethodMap. */
methodKey: keyof RequestMethodMap;
/** Value for the 'ex-module-action' header. */
action: string;
}
// ─── Execute Options ────────────────────────────────────────────
/**
* Options passed to the generic `execute()` method.
*/
export interface ExecuteOptions {
/** Dynamic URL parameters (e.g., `{ id: '42' }`). */
variableURL?: Record<string, string>;
/** Additional Axios request config (params, data, headers, etc). */
config?: AxiosRequestConfig;
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
}
// ─── Data Services Constructor ──────────────────────────────────
/**
* Configuration for constructing a BaseRemoteDataServices instance.
*/
export interface DataServicesConfig {
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
apiUrl?: string;
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
moduleKey?: string;
/** Override specific URL templates. */
urls?: Partial<ApiURLMap>;
/** Override specific HTTP methods. */
methods?: Partial<RequestMethodMap>;
}
@@ -0,0 +1,44 @@
/**
* Interpolates dynamic URL parameters.
*
* Replaces `:paramName` segments in a URL template with values
* from the provided variables object.
*
* @param template - URL template (e.g., '/bookings/:id/confirm')
* @param variables - Key-value map of parameter names to values
* @returns The interpolated URL string
*
* @example
* ```ts
* interpolateUrl('/bookings/:id/confirm', { id: '42' });
* // => '/bookings/42/confirm'
*
* interpolateUrl('/orgs/:orgId/users/:userId', { orgId: 'a', userId: 'b' });
* // => '/orgs/a/users/b'
* ```
*/
export function interpolateUrl(
template: string,
variables?: Record<string, string>,
): string {
if (!variables || Object.keys(variables).length === 0) {
return template;
}
return template
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
const key = segment.slice(1);
const value = variables[key];
if (value === undefined) {
throw new Error(
`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`,
);
}
return encodeURIComponent(value);
}
return segment;
})
.join('/');
}
+128
View File
@@ -0,0 +1,128 @@
import { AxiosError, type AxiosResponse } from 'axios';
import { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
/**
* Structured API error that normalizes Axios errors into a
* predictable, serializable format.
*
* Replaces the legacy `ErrorRequest` class with richer metadata.
*
* @example
* ```ts
* try {
* await apiClient.get('/users');
* } catch (err) {
* if (err instanceof ApiError) {
* console.log(err.code); // ApiErrorCode.UNAUTHORIZED
* console.log(err.status); // 401
* console.log(err.data); // { message: "Token expired" }
* }
* }
* ```
*/
export class ApiError extends Error {
/** Structured error code for programmatic handling. */
readonly code: ApiErrorCode;
/** HTTP status code (0 if no response, e.g., network error). */
readonly status: number;
/** Raw response body from the server, if available. */
readonly data: unknown;
/** The original Axios error, preserved for debugging. */
readonly cause: AxiosError | undefined;
constructor(
message: string,
code: ApiErrorCode,
status: number,
data?: unknown,
cause?: AxiosError,
) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
this.data = data;
this.cause = cause;
// Maintain proper prototype chain for instanceof checks
Object.setPrototypeOf(this, ApiError.prototype);
}
/**
* Factory: creates an ApiError from an AxiosError.
* Automatically resolves the error code from the HTTP status.
*/
static fromAxiosError(error: AxiosError<unknown>): ApiError {
// Network error (no response received)
if (!error.response) {
if (error.code === 'ECONNABORTED') {
return new ApiError(
'Request timed out',
ApiErrorCode.TIMEOUT,
0,
undefined,
error,
);
}
if (error.code === 'ERR_CANCELED') {
return new ApiError(
'Request was cancelled',
ApiErrorCode.CANCELLED,
0,
undefined,
error,
);
}
return new ApiError(
error.message || 'Network error',
ApiErrorCode.NETWORK_ERROR,
0,
undefined,
error,
);
}
// Server responded with an error status
const response: AxiosResponse = error.response;
const status = response.status;
const data = response.data;
const code = httpStatusToErrorCode(status);
// Extract message from common server response formats
const serverMessage =
(data && typeof data === 'object' && 'message' in data)
? String((data as Record<string, unknown>).message)
: `Request failed with status ${status}`;
return new ApiError(serverMessage, code, status, data, error);
}
/** Convenience check for authentication failures. */
get isUnauthorized(): boolean {
return this.code === ApiErrorCode.UNAUTHORIZED;
}
/** Convenience check for permission failures. */
get isForbidden(): boolean {
return this.code === ApiErrorCode.FORBIDDEN;
}
/** Convenience check for network/connectivity issues. */
get isNetworkError(): boolean {
return this.code === ApiErrorCode.NETWORK_ERROR;
}
/** JSON-serializable representation for logging/telemetry. */
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
code: this.code,
status: this.status,
data: this.data,
};
}
}
@@ -0,0 +1,42 @@
/**
* Enumeration of well-known API error codes.
*
* Use these to programmatically handle specific server responses
* without relying on magic strings scattered across the codebase.
*/
export enum ApiErrorCode {
// ─── HTTP Standard ────────────────────────────────────────────
BAD_REQUEST = 'BAD_REQUEST',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
CONFLICT = 'CONFLICT',
UNPROCESSABLE_ENTITY = 'UNPROCESSABLE_ENTITY',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
// ─── Client-side ──────────────────────────────────────────────
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
CANCELLED = 'CANCELLED',
UNKNOWN = 'UNKNOWN',
}
/**
* Maps HTTP status codes to ApiErrorCode enum values.
*/
export function httpStatusToErrorCode(status: number): ApiErrorCode {
switch (status) {
case 400: return ApiErrorCode.BAD_REQUEST;
case 401: return ApiErrorCode.UNAUTHORIZED;
case 403: return ApiErrorCode.FORBIDDEN;
case 404: return ApiErrorCode.NOT_FOUND;
case 409: return ApiErrorCode.CONFLICT;
case 422: return ApiErrorCode.UNPROCESSABLE_ENTITY;
case 429: return ApiErrorCode.TOO_MANY_REQUESTS;
case 500: return ApiErrorCode.INTERNAL_SERVER_ERROR;
case 503: return ApiErrorCode.SERVICE_UNAVAILABLE;
default: return ApiErrorCode.UNKNOWN;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { ApiError } from './api-error';
export { ApiErrorCode, httpStatusToErrorCode } from './error-codes';
@@ -0,0 +1,116 @@
import axios, { type AxiosInstance, type AxiosError } from 'axios';
import type { HttpClientConfig, InterceptorHooks } from './types';
import { noopObservabilityAdapter } from '../observability/noop.adapter';
import { ApiError } from '../errors/api-error';
/**
* Creates an isolated Axios instance with per-app configuration.
*
* **CRITICAL**: This function creates a NEW AxiosInstance on every call.
* It NEVER touches `axios.defaults` or `axios.interceptors`. Each consumer
* receives a fully autonomous client with its own interceptor chain.
*
* @param config - Base configuration (URL, timeout, headers, observability).
* @param hooks - Optional per-app interceptor hooks for auth, error handling, etc.
* @returns A configured, isolated AxiosInstance.
*
* @example
* ```ts
* // apps/web — full auth + telemetry
* const apiClient = createHttpClient(
* { baseURL: 'https://api.eigen.co/v1', observability: otelAdapter },
* {
* onRequest: async (config) => {
* config.headers.Authorization = `Bearer ${getToken()}`;
* return config;
* },
* onResponseError: async (error) => {
* if (error.response?.status === 401) redirect('/login');
* throw error;
* },
* },
* );
*
* // apps/landing — minimal public client
* const publicClient = createHttpClient({
* baseURL: 'https://api.eigen.co/public/v1',
* });
* ```
*/
export function createHttpClient(
config: HttpClientConfig,
hooks?: InterceptorHooks,
): AxiosInstance {
const observability = config.observability ?? noopObservabilityAdapter;
// ── Create isolated instance ──────────────────────────────────
const instance = axios.create({
baseURL: config.baseURL,
timeout: config.timeout ?? 15000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(config.defaultHeaders ?? {}),
},
});
// ── Request Interceptor Chain ─────────────────────────────────
instance.interceptors.request.use(
async (reqConfig) => {
// 1. Observability hook (tracing span start)
// Wrapped in try-catch: adapter failures must never block the request
try {
observability.onRequestStart(reqConfig);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestStart:', adapterError);
}
// 2. App-specific hook (e.g., inject auth token)
if (hooks?.onRequest) {
return hooks.onRequest(reqConfig);
}
return reqConfig;
},
(error) => Promise.reject(error),
);
// ── Response Interceptor Chain ────────────────────────────────
instance.interceptors.response.use(
(response) => {
// 1. Observability hook (tracing span end)
try {
observability.onRequestEnd(response);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestEnd:', adapterError);
}
// 2. App-specific response transform
if (hooks?.onResponse) {
return hooks.onResponse(response);
}
return response;
},
async (error: AxiosError) => {
// 1. Observability hook (tracing error record)
// CRITICAL: Wrapped in try-catch so adapter crashes never
// swallow the original API error from the UI.
try {
observability.onRequestError(error);
} catch (adapterError) {
console.warn('[core-api] Observability adapter error in onRequestError:', adapterError);
}
// 2. App-specific error handler (e.g., 401 redirect)
if (hooks?.onResponseError) {
return hooks.onResponseError(error);
}
// 3. Default: wrap in structured ApiError
throw ApiError.fromAxiosError(error);
},
);
return instance;
}
@@ -0,0 +1,12 @@
export { createHttpClient } from './create-http-client';
export type {
HttpClientConfig,
InterceptorHooks,
ApiResponse,
TelemetryContext,
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from './types';
+123
View File
@@ -0,0 +1,123 @@
import type {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import type { IObservabilityAdapter } from '../observability/types';
// ─── Factory Configuration ──────────────────────────────────────
/**
* Configuration for creating an isolated HTTP client instance.
* Each app provides its own config no globals are shared.
*/
export interface HttpClientConfig {
/** Base URL for all requests (e.g., 'https://api.eigen.co/v1'). */
baseURL: string;
/** Default request timeout in milliseconds. @default 15000 */
timeout?: number;
/** Default headers applied to every outgoing request. */
defaultHeaders?: Record<string, string>;
/**
* Observability adapter for tracing, logging, and metrics.
* If not provided, a zero-overhead No-Op adapter is used.
*/
observability?: IObservabilityAdapter;
}
// ─── Interceptor Hooks ──────────────────────────────────────────
/**
* Per-app hooks for customizing request/response behavior.
*
* These hooks are the app's "autonomy layer" each app decides
* how to inject tokens, handle 401s, transform responses, etc.
*/
export interface InterceptorHooks {
/**
* Called before every request is dispatched.
* Use this to inject authentication tokens, tenant headers, etc.
*/
onRequest?: (
config: InternalAxiosRequestConfig,
) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
/**
* Called on every successful response (2xx status).
* Use this to normalize response shapes if needed.
*/
onResponse?: (response: AxiosResponse) => AxiosResponse;
/**
* Called on every failed response (non-2xx or network error).
* Use this for app-specific error handling (e.g., redirect on 401).
* MUST throw or return a rejected promise.
*/
onResponseError?: (error: AxiosError) => Promise<never>;
}
// ─── Standardized API Response ──────────────────────────────────
/**
* Type-safe API response wrapper.
*
* Replaces the legacy `ResponseEntity` and the lost-in-callback
* `Promise<void>` return type with a fully typed contract.
*/
export interface ApiResponse<T = unknown> {
/** The parsed response body. */
data: T;
/** The HTTP status code. */
status: number;
}
// ─── Per-Request Telemetry Context ──────────────────────────────
/**
* Advanced escape hatch for per-request telemetry enrichment.
*
* Attach this to any request to push custom spans, tags, or
* business events into the observability pipeline.
*
* @example
* ```ts
* await bookingServices.getMany({
* telemetryContext: {
* customSpanName: 'booking.list.fetch',
* tags: { region: 'asia', priority: 'high' },
* pushEventOnSuccess: 'booking_list_loaded',
* },
* });
* ```
*/
export interface TelemetryContext {
/** Custom keys/tags to enrich the Faro log/error or OTel Span. */
tags?: Record<string, string | number | boolean>;
/** If provided, manually starts a custom OTel span wrapping this request. */
customSpanName?: string;
/** Force an explicit business event to be pushed to Faro on success. */
pushEventOnSuccess?: string;
}
// ─── Re-export Axios types consumers frequently need ────────────
export type {
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
// ─── Augment Axios to carry TelemetryContext ────────────────────
declare module 'axios' {
interface AxiosRequestConfig {
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
}
}
@@ -0,0 +1,5 @@
export type { IObservabilityAdapter } from './types';
export { noopObservabilityAdapter } from './noop.adapter';
export { faroAdapter } from './otel.adapter';
export { initTelemetry, getFaro } from './setup';
export type { TelemetryConfig } from './setup';
@@ -0,0 +1,17 @@
import type { IObservabilityAdapter } from './types';
/**
* No-Op Observability Adapter.
*
* Default adapter when no telemetry is configured.
* All methods are empty V8's TurboFan JIT compiler will inline
* and dead-code-eliminate these calls during optimization,
* resulting in effectively ZERO runtime overhead.
*
* Used by apps that don't need APM (e.g., `apps/landing`).
*/
export const noopObservabilityAdapter: IObservabilityAdapter = {
onRequestStart() {},
onRequestEnd() {},
onRequestError() {},
};
@@ -0,0 +1,385 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SpanStatusCode } from '@opentelemetry/api';
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError, AxiosHeaders } from 'axios';
// ─── Mock @opentelemetry/api ────────────────────────────────────
const mockSpan = {
setAttribute: vi.fn(),
setStatus: vi.fn(),
recordException: vi.fn(),
end: vi.fn(),
};
const mockTracer = {
startSpan: vi.fn(() => mockSpan),
};
vi.mock('@opentelemetry/api', () => ({
trace: {
getTracer: vi.fn(() => mockTracer),
},
SpanStatusCode: {
OK: 1,
ERROR: 2,
},
}));
// ─── Mock @grafana/faro-web-sdk ─────────────────────────────────
vi.mock('@grafana/faro-web-sdk', () => ({
LogLevel: {
DEBUG: 'debug',
ERROR: 'error',
},
}));
// ─── Mock getFaro() ─────────────────────────────────────────────
const mockFaroApi = {
pushLog: vi.fn(),
pushError: vi.fn(),
pushEvent: vi.fn(),
};
const mockFaro = { api: mockFaroApi };
vi.mock('./setup', () => ({
getFaro: vi.fn(() => mockFaro),
}));
// ─── Import SUT after mocks ─────────────────────────────────────
import { faroAdapter } from './otel.adapter';
// ─── Helpers ────────────────────────────────────────────────────
function createAxiosHeaders(headers: Record<string, string> = {}): AxiosHeaders {
// AxiosHeaders-compatible plain object for testing
return headers as unknown as AxiosHeaders;
}
function makeRequestConfig(
overrides: Partial<InternalAxiosRequestConfig> & Record<string, unknown> = {},
): InternalAxiosRequestConfig {
return {
method: 'get',
url: '/bookings',
headers: createAxiosHeaders(),
...overrides,
} as InternalAxiosRequestConfig;
}
function makeAxiosResponse(
config: InternalAxiosRequestConfig,
overrides: Partial<AxiosResponse> = {},
): AxiosResponse {
return {
data: {},
status: 200,
statusText: 'OK',
headers: {},
config,
...overrides,
} as AxiosResponse;
}
function makeAxiosError(
config: InternalAxiosRequestConfig | undefined,
status: number | undefined,
message = 'Request failed',
): AxiosError {
return {
isAxiosError: true,
name: 'AxiosError',
message,
config,
response: status ? { status, data: {}, headers: {}, statusText: 'Error', config } : undefined,
toJSON: () => ({}),
} as AxiosError;
}
// ─── Tests ──────────────────────────────────────────────────────
describe('faroAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── onRequestStart ────────────────────────────────────────────
describe('onRequestStart', () => {
it('pushes a Faro log with method and URL', () => {
const config = makeRequestConfig({
method: 'post',
url: '/users',
});
faroAdapter.onRequestStart(config);
expect(mockFaroApi.pushLog).toHaveBeenCalledOnce();
expect(mockFaroApi.pushLog).toHaveBeenCalledWith(
['[core-api] POST /users'],
expect.objectContaining({
level: 'debug',
context: expect.objectContaining({
'http.method': 'POST',
'http.url': '/users',
}),
}),
);
});
it('extracts ex-module-key and ex-module-action into Faro context', () => {
const config = makeRequestConfig({
headers: createAxiosHeaders({
'ex-module-key': 'BOOKING',
'ex-module-action': 'VIEW',
}),
});
faroAdapter.onRequestStart(config);
expect(mockFaroApi.pushLog).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
context: expect.objectContaining({
'module.key': 'BOOKING',
'module.action': 'VIEW',
}),
}),
);
});
it('includes telemetryContext.tags in Faro context', () => {
const config = makeRequestConfig({
telemetryContext: {
tags: { region: 'asia', priority: 'high' },
},
});
faroAdapter.onRequestStart(config);
expect(mockFaroApi.pushLog).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
context: expect.objectContaining({
region: 'asia',
priority: 'high',
}),
}),
);
});
it('creates a custom span when customSpanName is provided', () => {
const config = makeRequestConfig({
method: 'get',
url: '/bookings',
headers: createAxiosHeaders({
'ex-module-key': 'BOOKING',
'ex-module-action': 'VIEW',
}),
telemetryContext: {
customSpanName: 'booking.list.fetch',
tags: { feature: 'booking' },
},
});
faroAdapter.onRequestStart(config);
expect(mockTracer.startSpan).toHaveBeenCalledWith('booking.list.fetch', {
attributes: expect.objectContaining({
'http.method': 'GET',
'http.url': '/bookings',
'custom.module_key': 'BOOKING',
'custom.module_action': 'VIEW',
}),
});
// Custom tags are attached with `custom.` prefix
expect(mockSpan.setAttribute).toHaveBeenCalledWith('custom.feature', 'booking');
});
it('does NOT create a span when no customSpanName is provided', () => {
const config = makeRequestConfig();
faroAdapter.onRequestStart(config);
expect(mockTracer.startSpan).not.toHaveBeenCalled();
});
});
// ── onRequestEnd ──────────────────────────────────────────────
describe('onRequestEnd', () => {
it('closes the custom span with OK status', () => {
// First create the span
const config = makeRequestConfig({
telemetryContext: { customSpanName: 'test.span' },
});
faroAdapter.onRequestStart(config);
vi.clearAllMocks();
// Then end the request
const response = makeAxiosResponse(config, { status: 200 });
faroAdapter.onRequestEnd(response);
expect(mockSpan.setAttribute).toHaveBeenCalledWith('http.status_code', 200);
expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
expect(mockSpan.end).toHaveBeenCalledOnce();
});
it('pushes a Faro event when pushEventOnSuccess is configured', () => {
const config = makeRequestConfig({
method: 'get',
url: '/bookings',
telemetryContext: {
pushEventOnSuccess: 'booking_list_loaded',
tags: { page: 1 },
},
});
const response = makeAxiosResponse(config, { status: 200 });
faroAdapter.onRequestEnd(response);
expect(mockFaroApi.pushEvent).toHaveBeenCalledWith(
'booking_list_loaded',
expect.objectContaining({
'http.status_code': '200',
'http.url': '/bookings',
page: '1',
}),
);
});
it('does NOT push an event when no pushEventOnSuccess is configured', () => {
const config = makeRequestConfig();
const response = makeAxiosResponse(config);
faroAdapter.onRequestEnd(response);
expect(mockFaroApi.pushEvent).not.toHaveBeenCalled();
});
it('detaches span reference after closing (prevents double-close on retry)', () => {
const config = makeRequestConfig({
telemetryContext: { customSpanName: 'retry.test' },
});
faroAdapter.onRequestStart(config);
vi.clearAllMocks();
const response = makeAxiosResponse(config);
faroAdapter.onRequestEnd(response);
expect(mockSpan.end).toHaveBeenCalledOnce();
// Second call should NOT close the span again
vi.clearAllMocks();
faroAdapter.onRequestEnd(response);
expect(mockSpan.end).not.toHaveBeenCalled();
});
});
// ── onRequestError ────────────────────────────────────────────
describe('onRequestError', () => {
it('pushes a Faro error with enriched context', () => {
const config = makeRequestConfig({
method: 'post',
url: '/bookings',
headers: createAxiosHeaders({
'ex-module-key': 'BOOKING',
'ex-module-action': 'CREATE',
}),
});
const error = makeAxiosError(config, 422, 'Validation failed');
faroAdapter.onRequestError(error);
expect(mockFaroApi.pushError).toHaveBeenCalledWith(
error,
expect.objectContaining({
type: 'api_error',
context: expect.objectContaining({
'http.method': 'POST',
'http.url': '/bookings',
'http.status_code': '422',
'module.key': 'BOOKING',
'module.action': 'CREATE',
'error.message': 'Validation failed',
}),
}),
);
});
it('pushes a Faro log at ERROR level', () => {
const config = makeRequestConfig({ url: '/users' });
const error = makeAxiosError(config, 500, 'Internal Server Error');
faroAdapter.onRequestError(error);
expect(mockFaroApi.pushLog).toHaveBeenCalledWith(
['[core-api] ERROR GET /users → 500'],
expect.objectContaining({
level: 'error',
context: expect.objectContaining({
'http.status_code': '500',
'error.message': 'Internal Server Error',
}),
}),
);
});
it('closes the custom span with ERROR status', () => {
const config = makeRequestConfig({
telemetryContext: { customSpanName: 'error.test' },
});
faroAdapter.onRequestStart(config);
vi.clearAllMocks();
const error = makeAxiosError(config, 500, 'Server Error');
faroAdapter.onRequestError(error);
expect(mockSpan.setAttribute).toHaveBeenCalledWith('http.status_code', 500);
expect(mockSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Server Error',
});
expect(mockSpan.recordException).toHaveBeenCalledWith(error);
expect(mockSpan.end).toHaveBeenCalledOnce();
});
it('handles undefined error.config gracefully (network timeout)', () => {
const error = makeAxiosError(undefined, undefined, 'Network Error');
// Should not throw
expect(() => faroAdapter.onRequestError(error)).not.toThrow();
// Should still push error with fallback values
expect(mockFaroApi.pushError).toHaveBeenCalledWith(
error,
expect.objectContaining({
context: expect.objectContaining({
'http.method': 'UNKNOWN',
'http.url': '/',
'http.status_code': '0',
}),
}),
);
});
it('includes telemetryContext.tags in error Faro context', () => {
const config = makeRequestConfig({
telemetryContext: { tags: { region: 'eu', critical: true } },
});
const error = makeAxiosError(config, 503, 'Service Unavailable');
faroAdapter.onRequestError(error);
expect(mockFaroApi.pushError).toHaveBeenCalledWith(
error,
expect.objectContaining({
context: expect.objectContaining({
region: 'eu',
critical: 'true',
}),
}),
);
});
});
});
@@ -0,0 +1,212 @@
import { trace, SpanStatusCode, type Span } from '@opentelemetry/api';
import { LogLevel } from '@grafana/faro-web-sdk';
import type { IObservabilityAdapter } from './types';
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import type { TelemetryContext } from '../http-client/types';
import { getFaro } from './setup';
// ─── Symbol Keys ────────────────────────────────────────────────
/** Symbol-keyed storage for custom spans on the Axios config. */
const CUSTOM_SPAN_KEY = Symbol('__customOtelSpan');
function attachSpan(config: InternalAxiosRequestConfig, span: Span): void {
(config as unknown as Record<symbol, Span>)[CUSTOM_SPAN_KEY] = span;
}
function getSpan(config: unknown): Span | undefined {
if (!config) return undefined;
return (config as Record<symbol, Span>)?.[CUSTOM_SPAN_KEY];
}
/**
* Safely close a span, guarding against double-close.
* After ending, removes the reference from the config to prevent
* duplicate Span ID errors on potential Axios retries.
*/
function safeEndSpan(config: unknown, span: Span): void {
span.end();
// Detach from config to prevent double-close on retry
if (config) {
delete (config as Record<symbol, unknown>)[CUSTOM_SPAN_KEY];
}
}
/** Extract TelemetryContext from an Axios config. */
function getTelemetryContext(config: unknown): TelemetryContext | undefined {
if (!config) return undefined;
return (config as Record<string, unknown>)?.telemetryContext as TelemetryContext | undefined;
}
// ─── Helpers ────────────────────────────────────────────────────
/** Convert TelemetryContext tags to a string record for Faro context. */
function tagsToFaroContext(tags?: Record<string, string | number | boolean>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(
Object.entries(tags).map(([k, v]) => [k, String(v)]),
);
}
/**
* Build the standardized base context used by ALL Faro pushLog/pushError calls.
* Ensures `module.key`, `module.action`, and tags are always at the top-level
* `context` object making them directly queryable in LogQL (Loki).
*/
function buildBaseContext(
method: string,
url: string,
moduleKey?: string,
action?: string,
tags?: Record<string, string | number | boolean>,
): Record<string, string> {
return {
'http.method': method,
'http.url': url,
...(moduleKey ? { 'module.key': moduleKey } : {}),
...(action ? { 'module.action': action } : {}),
...tagsToFaroContext(tags),
};
}
// ─── Adapter ────────────────────────────────────────────────────
/**
* Production-grade Observability Adapter.
*
* Strategy: **Opt-In Custom Spans + Faro/Loki Baseline Logging**
*
* `trace.getActiveSpan()` returns `undefined` inside Axios interceptors
* due to browser XHR/Fetch lifecycle race conditions with Faro's
* `TracingInstrumentation`. Therefore this adapter does NOT attempt
* to enrich auto-instrumented spans.
*
* Instead it focuses on two responsibilities:
*
* 1. **Custom Span Mode** (opt-in via `telemetryContext.customSpanName`):
* Creates an explicit OTel span, attaches business tags, and ensures
* the span is ALWAYS closed even on abort, timeout, or unexpected
* errors to prevent span leaks.
*
* 2. **Faro/Loki Baseline** (always):
* Pushes rich contextual logs (`pushLog`), errors (`pushError`), and
* success events (`pushEvent`) with standardized `baseContext` for
* direct LogQL queryability.
*
* Safety guarantees:
* - Spans are always closed via `safeEndSpan()` which detaches the
* reference after closing, preventing double-close on Axios retries.
* - Adapter errors are caught internally and never swallowed the
* original API error always propagates to the UI.
* - `null`/`undefined` config guards prevent crashes on network timeouts
* where `error.config` may be undefined.
*/
export const faroAdapter: IObservabilityAdapter = {
onRequestStart(config: InternalAxiosRequestConfig) {
const ctx = getTelemetryContext(config);
const method = (config.method ?? 'UNKNOWN').toUpperCase();
const url = config.url ?? '/';
const moduleKey = config.headers?.['ex-module-key'] as string | undefined;
const action = config.headers?.['ex-module-action'] as string | undefined;
// ── 1. Create optional custom span ──────────────────────────
if (ctx?.customSpanName) {
const tracer = trace.getTracer('@repo/core-api', '1.0.0');
const span = tracer.startSpan(ctx.customSpanName, {
attributes: {
'http.method': method,
'http.url': url,
...(moduleKey ? { 'custom.module_key': moduleKey } : {}),
...(action ? { 'custom.module_action': action } : {}),
},
});
// Attach custom tags with `custom.` prefix
if (ctx.tags) {
for (const [key, value] of Object.entries(ctx.tags)) {
span.setAttribute(`custom.${key}`, value);
}
}
attachSpan(config, span);
}
// ── 2. Push structured log (Loki) ───────────────────────────
const faro = getFaro();
if (faro) {
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
faro.api.pushLog(
[`[core-api] ${method} ${url}`],
{ level: LogLevel.DEBUG, context: baseContext },
);
}
},
onRequestEnd(response: AxiosResponse) {
const ctx = getTelemetryContext(response.config);
const moduleKey = response.config?.headers?.['ex-module-key'] as string | undefined;
const action = response.config?.headers?.['ex-module-action'] as string | undefined;
// ── 1. Close custom span (OK) ───────────────────────────────
const customSpan = getSpan(response.config);
if (customSpan) {
customSpan.setAttribute('http.status_code', response.status);
customSpan.setStatus({ code: SpanStatusCode.OK });
safeEndSpan(response.config, customSpan);
}
// ── 2. Push success event (Faro) ────────────────────────────
if (ctx?.pushEventOnSuccess) {
const faro = getFaro();
if (faro) {
const method = (response.config?.method ?? 'UNKNOWN').toUpperCase();
const url = response.config?.url ?? '/';
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx.tags);
faro.api.pushEvent(ctx.pushEventOnSuccess, {
...baseContext,
'http.status_code': String(response.status),
});
}
}
},
onRequestError(error: AxiosError) {
const ctx = getTelemetryContext(error.config);
const status = error.response?.status ?? 0;
const method = (error.config?.method ?? 'UNKNOWN').toUpperCase();
const url = error.config?.url ?? '/';
const moduleKey = error.config?.headers?.['ex-module-key'] as string | undefined;
const action = error.config?.headers?.['ex-module-action'] as string | undefined;
// Standardized context for ALL Faro calls in this handler
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
const errorContext: Record<string, string> = {
...baseContext,
'http.status_code': String(status),
'error.message': error.message,
};
// ── 1. Close custom span with error (if present) ────────────
const customSpan = getSpan(error.config);
if (customSpan) {
customSpan.setAttribute('http.status_code', status);
customSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
customSpan.recordException(error);
safeEndSpan(error.config, customSpan);
}
// ── 2. Push structured error + log (Faro → Loki) ────────────
const faro = getFaro();
if (faro) {
faro.api.pushError(error, {
type: 'api_error',
context: errorContext,
});
faro.api.pushLog(
[`[core-api] ERROR ${method} ${url}${status}`],
{ level: LogLevel.ERROR, context: errorContext },
);
}
},
};
@@ -0,0 +1,151 @@
/**
* Centralized Telemetry Setup Grafana Faro + OpenTelemetry.
*
* Provides a plug-and-play `initTelemetry()` function that hides
* all Faro/OTel complexity behind a simple config interface.
*
* @example
* ```ts
* // apps/web/src/main.tsx (top of file)
* import { initTelemetry } from '@repo/core-api/observability/setup';
* initTelemetry({
* appName: 'web',
* appVersion: '1.0.0',
* telemetryUrl: 'https://telemetry.eigen.co.id/collect',
* environment: 'production',
* });
* ```
*/
import {
getWebInstrumentations,
initializeFaro,
type Faro,
} from '@grafana/faro-react';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
// ─── Configuration Interface ────────────────────────────────────
/**
* Configuration for initializing the full telemetry stack.
* Apps pass this once at startup everything else is automatic.
*/
export interface TelemetryConfig {
/** Application name for Faro + OTel resource attributes. */
appName: string;
/** Application version (SemVer). */
appVersion: string;
/** Grafana Faro collector URL (e.g., 'https://telemetry.eigen.co.id/collect'). */
telemetryUrl: string;
/** Deployment environment ('production', 'staging', 'development'). */
environment: string;
/**
* Optional: Separate OTLP trace endpoint for direct Tempo ingestion.
* If not provided, traces are only sent through Faro's built-in exporter.
*/
otlpTraceUrl?: string;
/**
* Optional: CORS URL patterns for W3C trace context propagation.
* @default [/.* /]
*/
propagateTraceHeaderCorsUrls?: Array<string | RegExp>;
}
// ─── Session Persistence ────────────────────────────────────────
const FARO_SESSION_KEY = 'faroSession';
function getStoredSession(): Record<string, unknown> | null {
try {
const data = localStorage.getItem(FARO_SESSION_KEY);
return data ? JSON.parse(data) : null;
} catch {
return null;
}
}
// ─── Singleton ──────────────────────────────────────────────────
let faroInstance: Faro | null = null;
/**
* Initializes the Grafana Faro + OpenTelemetry observability stack.
*
* Call this ONCE at the top of your app's entry point, before any
* React code, HTTP requests, or other imports execute.
*
* `TracingInstrumentation` internally handles:
* - WebTracerProvider setup with resource attributes
* - FaroMetaAttributesSpanProcessor (session/user enrichment)
* - FaroTraceExporter (sends spans to the Faro collector)
* - Auto-instrumentation for fetch/XHR
* - `faro.api.initOTEL(trace, context)` bridge
*
* @returns The initialized Faro instance for advanced usage.
*/
export function initTelemetry(config: TelemetryConfig): Faro {
if (faroInstance) return faroInstance;
const storedSession = getStoredSession();
// Build optional extra span processors
const tracingOptions: Record<string, unknown> = {};
if (config.otlpTraceUrl) {
tracingOptions.spanProcessor = new BatchSpanProcessor(
new OTLPTraceExporter({
url: config.otlpTraceUrl,
headers: {},
}),
);
}
faroInstance = initializeFaro({
url: config.telemetryUrl,
app: {
name: config.appName,
version: config.appVersion,
environment: config.environment,
},
sessionTracking: {
enabled: true,
persistent: true,
session: storedSession ?? undefined,
onSessionChange: (_oldSession, newSession) => {
if (newSession) {
localStorage.setItem(FARO_SESSION_KEY, JSON.stringify(newSession));
}
},
},
instrumentations: [
...getWebInstrumentations(),
new TracingInstrumentation({
...tracingOptions,
instrumentationOptions: {
propagateTraceHeaderCorsUrls:
config.propagateTraceHeaderCorsUrls ?? [/.*/],
fetchInstrumentationOptions: {
applyCustomAttributesOnSpan(span) {
span.setAttribute('app.synthetic_request', 'false');
},
},
xhrInstrumentationOptions: {
applyCustomAttributesOnSpan(span) {
span.setAttribute('app.synthetic_request', 'false');
},
},
},
}),
],
});
return faroInstance;
}
/** Returns the Faro instance (null if not yet initialized). */
export function getFaro(): Faro | null {
return faroInstance;
}
@@ -0,0 +1,22 @@
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
/**
* Interface-driven observability contract.
*
* The HTTP client calls these hooks at request lifecycle points.
* Implementations decide whether to trace, log, metric, or do nothing.
*
* This interface is the ONLY dependency between the HTTP client and
* any telemetry library. The core-api package NEVER imports
* OpenTelemetry, Datadog, Sentry, or any vendor SDK directly.
*/
export interface IObservabilityAdapter {
/** Called immediately before a request is dispatched. */
onRequestStart(config: InternalAxiosRequestConfig): void;
/** Called when a response is successfully received. */
onRequestEnd(response: AxiosResponse): void;
/** Called when a request fails (network error or error status). */
onRequestError(error: AxiosError): void;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}
+183
View File
@@ -0,0 +1,183 @@
# Enterprise i18n Architecture (`@repo/core-i18n`)
A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
It uses a **Hybrid Namespace Strategy**:
1. **Centralized Engine**: Setup, local persistence (`@repo/core-storage`), and global words (`common`).
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API and networking decisions entirely to the consuming applications.
---
## 1. App-Level Setup (Bootstrap)
Initialize the engine *before* your React application mounts to prevent UI flashing.
```tsx
// apps/web/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import App from './app';
async function bootstrap() {
// Synchronously reads preferred language from storage & inits i18next
await setupI18n();
createRoot(document.getElementById('app')!).render(
<StrictMode><App /></StrictMode>,
);
}
bootstrap();
```
---
## 2. Module-Level Setup (Decentralized Dictionaries)
Dictionaries live right next to the UI components that use them.
### Folder Structure
```text
apps/web/src/apps/modules/booking/
├── presentation/BookingTable.tsx
└── locales/
├── id/booking.json
└── en/booking.json
```
### Lazy Loading & Type Safety
Register the namespace when the component mounts. To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types.
**1. Augment Types:**
```ts
// apps/web/src/types/i18next.d.ts
import 'react-i18next';
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
import bookingEn from '../apps/modules/booking/locales/en/booking.json';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof coreResources['en'] & { booking: typeof bookingEn };
}
}
```
**2. Use in Component:**
```tsx
import { useEffect } from 'react';
import { i18n, useTranslation } from '@repo/core-i18n';
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
export default function BookingFeature() {
const { t } = useTranslation(['common', 'booking']);
useEffect(() => {
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
}, []);
return <h1>{t('booking:header.title')}</h1>; // Autocomplete works!
}
```
---
## 3. Real-World Implementation Flow
The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization.
### A. The Tenant Override Flow (After Login)
If "Company A" calls "Purchasing" -> "Procurement", they shouldn't need a custom build. The backend returns an override config, and the frontend dynamically merges it using `applyTenantOverrides`.
```tsx
// Example inside an AuthProvider or Post-Login useEffect
import { useEffect } from 'react';
import { applyTenantOverrides } from '@repo/core-i18n';
import { api } from '@/api';
export function AuthProvider({ children }) {
useEffect(() => {
async function fetchTenantConfig() {
try {
// 1. Fetch tenant-specific overrides from the API
const response = await api.get('/v1/tenant/i18n-config');
// 2. Inject into the engine.
// `deep: true` ensures only provided keys are overridden.
applyTenantOverrides(
response.data.namespace,
response.data.overrides
);
} catch (err) {
console.error("Failed to fetch tenant configuration", err);
}
}
fetchTenantConfig();
}, []);
return <>{children}</>;
}
```
### B. User Preference Sync (With Rollback)
When a logged-in user changes their language, we update the UI instantly, save it locally, and sync it to the backend. If the backend fails, the engine automatically rolls back.
```tsx
import { changeLanguage } from '@repo/core-i18n';
import { api } from '@/api';
const handleSwitch = async (newLng: string) => {
try {
await changeLanguage(newLng, async (lng) => {
// The core engine waits for this Promise.
// If it throws, the UI reverts to the previous language automatically.
await api.patch('/v1/user/profile', { language: lng });
});
toast.success('Language saved!');
} catch (err) {
toast.error('Sync failed. Reverted to previous language.');
}
};
```
> [!NOTE]
> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
---
## 4. Backend API Contract (For Backend Engineers)
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
### Identification
The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
### Expected JSON Response Format
The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
**Example Request:**
`GET /v1/tenant/i18n-config`
*(Authorization: Bearer eyJhbG...)*
**Expected Response (200 OK):**
```json
{
"data": {
"namespace": "booking",
"overrides": {
"module_name": "Procurement",
"header": {
"title": "Procurement List"
}
}
}
}
```
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@repo/core-i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./react-i18next": "./src/react-i18next.d.ts"
},
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-storage": "workspace:*",
"@repo/utils": "workspace:*",
"i18next": "^24.2.2",
"react-i18next": "^15.4.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.0.8",
"typescript": "5.5.4"
}
}
+4
View File
@@ -0,0 +1,4 @@
export { setupI18n, type SupportedLanguage } from './setup';
export { changeLanguage, applyTenantOverrides } from './manager';
export { useTranslation, Trans } from 'react-i18next';
export { default as i18n } from 'i18next';
@@ -0,0 +1,12 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"success": "Success",
"error": "Error",
"settings": "Settings",
"loading": "Loading...",
"delete": "Delete",
"edit": "Edit"
}
}
@@ -0,0 +1,12 @@
{
"common": {
"save": "Simpan",
"cancel": "Batal",
"success": "Sukses",
"error": "Galat",
"settings": "Pengaturan",
"loading": "Memuat...",
"delete": "Hapus",
"edit": "Ubah"
}
}
+56
View File
@@ -0,0 +1,56 @@
import i18n from 'i18next';
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
/**
* Changes the active language, saves the preference locally, and optionally syncs with the backend.
*
* @param newLng The new language code (e.g., 'en', 'id')
* @param syncCallback An optional callback to sync the preference to the backend. It receives the new language and the previous language.
*/
export async function changeLanguage(
newLng: string,
syncCallback?: (newLng: string, prevLng: string) => Promise<void>
): Promise<void> {
const prevLng = i18n.language;
if (prevLng === newLng) return;
// 1. Update local storage & i18next optimistically
await demoSecureStorage.setItem(StorageKey.LOCALE, newLng);
await i18n.changeLanguage(newLng);
// 2. Trigger optional backend sync
if (syncCallback) {
try {
await syncCallback(newLng, prevLng);
} catch (error) {
console.error('[i18n] Backend sync failed, rolling back language', error);
// Rollback on failure
await demoSecureStorage.setItem(StorageKey.LOCALE, prevLng);
await i18n.changeLanguage(prevLng);
throw error; // Rethrow so the caller can show an error toast
}
}
}
/**
* Injects tenant-specific vocabulary overrides dynamically at runtime.
*
* Uses a deep-merge strategy. Overrides are applied to the currently active language,
* or across all loaded languages if needed.
*
* @param namespace The i18n namespace to override (e.g., 'common', 'booking')
* @param overrides A deeply nested object containing the overridden string keys and values.
* @param lng Specific language to override. Defaults to currently active language.
*/
export function applyTenantOverrides(
namespace: string,
overrides: Record<string, unknown>,
lng?: string
): void {
const targetLng = lng || i18n.language;
// deep: true -> merges with existing keys rather than replacing the whole namespace
// overwrite: true -> allows replacing existing specific keys
i18n.addResourceBundle(targetLng, namespace, overrides, true, true);
}
+9
View File
@@ -0,0 +1,9 @@
import 'react-i18next';
import type { resources } from './setup';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof resources['en'];
}
}
+57
View File
@@ -0,0 +1,57 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
import commonEn from './locales/en/common.json';
import commonId from './locales/id/common.json';
const DEFAULT_LANGUAGE = 'id';
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const resources = {
en: { common: commonEn.common },
id: { common: commonId.common },
} as const;
/**
* Bootstraps the central i18n engine.
*
* This reads the preferred locale from secureStorage and initializes
* i18next synchronously before React renders.
*/
export async function setupI18n(): Promise<void> {
let initialLng = DEFAULT_LANGUAGE;
try {
const storedLng = await demoSecureStorage.getItem<string>(StorageKey.LOCALE);
if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) {
initialLng = storedLng;
}
} catch (err) {
console.warn('[i18n] Failed to read locale from storage', err);
}
await i18n
.use(initReactI18next)
.init({
resources,
lng: initialLng,
fallbackLng: DEFAULT_LANGUAGE,
defaultNS: 'common',
interpolation: {
escapeValue: false, // React already escapes values
},
});
// Apply initial language to the DOM for SEO/Accessibility
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
// Ensure DOM updates whenever the language changes later
i18n.on('languageChanged', (lng) => {
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}
+114
View File
@@ -0,0 +1,114 @@
# @repo/core-storage
The **Enterprise-grade storage engine** for the monorepo.
This package provides a unified, Promise-based interface for interacting with browser storage (`localStorage` and `IndexedDB`). It enforces strict type safety, prevents key collisions, and automatically provides **AES encryption at rest** for sensitive payloads (like access tokens) using `@repo/utils`.
---
## 🎯 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.
* **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<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
### 1. Secure Local Storage (Tokens, Profile)
Use `demoSecureStorage` (or your app's configured instance) for small payloads. If the key is in `ENCRYPTED_KEYS`, it will be encrypted at rest.
```typescript
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
import type { UserProfile } from '@/types';
// CREATE / UPDATE
// If StorageKey.USER_PROFILE is in ENCRYPTED_KEYS, this is AES-encrypted automatically.
await demoSecureStorage.setItem(StorageKey.USER_PROFILE, {
id: 1,
name: 'Firman',
role: 'admin'
});
// READ
const profile = await demoSecureStorage.getItem<UserProfile>(StorageKey.USER_PROFILE);
if (profile) {
console.log('Welcome back,', profile.name);
}
// DELETE
await demoSecureStorage.removeItem(StorageKey.USER_PROFILE);
```
### 2. IndexedDB (Offline Data, Large Payloads)
Use the pre-configured `demoIndexedDB` (or instantiate your own) for large, asynchronous data. It uses the exact same API and encryption pipeline.
```typescript
import { demoIndexedDB } from '@repo/core-storage';
interface DraftData {
id: string;
content: string;
lastModified: number;
}
// Save a large draft offline
await demoIndexedDB.setItem('offline_draft_123', {
id: '123',
content: 'Huge text content...',
lastModified: Date.now()
});
// Retrieve the draft
const draft = await demoIndexedDB.getItem<DraftData>('offline_draft_123');
```
---
## 🔑 Adding New Keys
To maintain type safety and avoid collisions, **all** `localStorage` keys must be registered in `packages/core-storage/src/storage.key.ts`.
### 1. Register the Key
Add your key to the `StorageKey` object:
```typescript
export const StorageKey = {
// ... existing keys
MY_NEW_FEATURE: 'my_new_feature_key',
} as const;
```
### 2. Define Encryption (If Needed)
If the data stored under this key is sensitive (e.g., PII, tokens, financials), add it to the `ENCRYPTED_KEYS` set.
```typescript
export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
StorageKey.ACCESS_TOKEN,
StorageKey.REFRESH_TOKEN,
StorageKey.USER_PROFILE,
StorageKey.MY_NEW_FEATURE, // <--- Now encrypted at rest!
]);
```
> [!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.
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@repo/core-storage",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"license": "MIT",
"scripts": {
"lint": "eslint \"**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/utils": "workspace:*"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"eslint": "^8.57.1",
"typescript": "5.5.4",
"vitest": "^4.0.17"
}
}
+49
View File
@@ -0,0 +1,49 @@
// ─── Interfaces ─────────────────────────────────────────────────
export type { IStorageService } from './storage.interface';
// ─── Key Registry ───────────────────────────────────────────────
export { StorageKey, ENCRYPTED_KEYS } from './storage.key';
export type { StorageKeyValue } from './storage.key';
// ─── Service Classes ────────────────────────────────────────────
export { LocalStorageService } from './local-storage.service';
export { IndexedDBService } from './indexed-db.service';
// ─── Pre-configured Instances ───────────────────────────────────
import { LocalStorageService } from './local-storage.service';
import { IndexedDBService } from './indexed-db.service';
/**
* Default secure localStorage instance.
*
* Keys listed in `ENCRYPTED_KEYS` are automatically encrypted via
* `@repo/utils` `EncryptionUtils`. All other keys are plain JSON.
*
* @example
* ```ts
* import { demoSecureStorage, StorageKey } from '@repo/core-storage';
*
* await demoSecureStorage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
* const token = await demoSecureStorage.getItem<string>(StorageKey.ACCESS_TOKEN);
* ```
*/
export const demoSecureStorage = new LocalStorageService();
/**
* Default IndexedDB instance.
*
* Uses `app_db` database with a `kv_store` object store.
* Sensitive keys are encrypted at rest using the same
* `EncryptionUtils` pipeline as `demoSecureStorage`.
*
* @example
* ```ts
* import { demoIndexedDB } from '@repo/core-storage';
*
* await demoIndexedDB.setItem('offline_draft', { content: '...' });
* const draft = await demoIndexedDB.getItem<Draft>('offline_draft');
* ```
*/
export const demoIndexedDB = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store' });
export const demoIndexedDB2 = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store_2' });
@@ -0,0 +1,174 @@
import { EncryptionUtils } from '@repo/utils';
import type { IStorageService } from './storage.interface';
import { ENCRYPTED_KEYS } from './storage.key';
// ─── Types ──────────────────────────────────────────────────────
interface IndexedDBConfig {
/** Database name. @default 'app_db' */
dbName?: string;
/** Object store name. @default 'kv_store' */
storeName?: string;
/** Database version. @default 1 */
version?: number;
}
// ─── Helpers ────────────────────────────────────────────────────
/**
* Open (or create) an IndexedDB database with a simple key-value store.
* Returns a Promise that resolves with the IDBDatabase instance.
*/
function openDatabase(
dbName: string,
storeName: string,
version: number,
): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Execute a single IndexedDB transaction and return the result.
* Handles open transaction request close lifecycle cleanly.
*/
function withTransaction<R>(
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode,
operation: (store: IDBObjectStore) => IDBRequest<R>,
): Promise<R> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const request = operation(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// ─── Service ────────────────────────────────────────────────────
/**
* Enterprise-grade IndexedDB wrapper with optional AES encryption.
*
* Uses a simple key-value object store pattern. Keys listed in
* `ENCRYPTED_KEYS` are automatically encrypted/decrypted using
* `@repo/utils` `EncryptionUtils`.
*
* Unlike localStorage, IndexedDB has no 5MB size limit making
* it suitable for large payloads like cached API responses, offline
* data, or file blobs.
*
* @example
* ```ts
* const idb = new IndexedDBService({ dbName: 'my_app' });
* await idb.setItem('large_dataset', hugePayload);
* const data = await idb.getItem<HugePayload>('large_dataset');
* ```
*/
export class IndexedDBService implements IStorageService {
private readonly encryption: EncryptionUtils;
private readonly dbName: string;
private readonly storeName: string;
private readonly version: number;
private dbPromise: Promise<IDBDatabase> | null = null;
constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) {
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
this.dbName = config?.dbName ?? 'app_db';
this.storeName = config?.storeName ?? 'kv_store';
this.version = config?.version ?? 1;
}
/** Lazy-open the database connection (cached). */
private getDB(): Promise<IDBDatabase> {
if (!this.dbPromise) {
this.dbPromise = openDatabase(this.dbName, this.storeName, this.version);
}
return this.dbPromise;
}
private shouldEncrypt(key: string): boolean {
return ENCRYPTED_KEYS.has(key);
}
async setItem<T>(key: string, value: T): Promise<void> {
const db = await this.getDB();
const serialized = JSON.stringify(value);
const payload = this.shouldEncrypt(key)
? this.encryption.encrypt(serialized)
: serialized;
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.put(payload, key),
);
}
async getItem<T>(key: string): Promise<T | null> {
const db = await this.getDB();
const raw = await withTransaction<string | undefined>(
db,
this.storeName,
'readonly',
(store) => store.get(key) as IDBRequest<string | undefined>,
);
if (raw === undefined || raw === null) return null;
try {
if (this.shouldEncrypt(key)) {
const decrypted = this.encryption.decrypt(raw);
if (!decrypted) return null;
return JSON.parse(decrypted) as T;
}
return JSON.parse(raw) as T;
} catch {
console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`);
await this.removeItem(key);
return null;
}
}
async removeItem(key: string): Promise<void> {
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.delete(key),
);
}
async clear(): Promise<void> {
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.clear(),
);
}
async hasItem(key: string): Promise<boolean> {
const value = await this.getItem(key);
return value !== null;
}
async keys(): Promise<string[]> {
const db = await this.getDB();
return withTransaction<string[]>(
db,
this.storeName,
'readonly',
(store) => store.getAllKeys() as IDBRequest<string[]>,
);
}
}
@@ -0,0 +1,88 @@
import { EncryptionUtils } from '@repo/utils';
import type { IStorageService } from './storage.interface';
import { ENCRYPTED_KEYS } from './storage.key';
/**
* Enterprise-grade localStorage wrapper with optional AES encryption.
*
* Keys listed in `ENCRYPTED_KEYS` are automatically encrypted before
* writing and decrypted on read using `@repo/utils` `EncryptionUtils`.
* All other keys are stored as plain JSON.
*
* All methods are async (returning Promises) to conform to the
* `IStorageService` interface, ensuring consumers can swap between
* localStorage and IndexedDB without code changes.
*
* @example
* ```ts
* const storage = new LocalStorageService();
*
* // Encrypted at rest (ACCESS_TOKEN is in ENCRYPTED_KEYS)
* await storage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
*
* // Plain JSON (THEME is NOT in ENCRYPTED_KEYS)
* await storage.setItem(StorageKey.THEME, 'dark');
* ```
*/
export class LocalStorageService implements IStorageService {
private readonly encryption: EncryptionUtils;
constructor(encryptionUtils?: EncryptionUtils) {
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
}
/** Check if a key should be encrypted. */
private shouldEncrypt(key: string): boolean {
return ENCRYPTED_KEYS.has(key);
}
async setItem<T>(key: string, value: T): Promise<void> {
const serialized = JSON.stringify(value);
if (this.shouldEncrypt(key)) {
const encrypted = this.encryption.encrypt(serialized);
localStorage.setItem(key, encrypted);
} else {
localStorage.setItem(key, serialized);
}
}
async getItem<T>(key: string): Promise<T | null> {
const raw = localStorage.getItem(key);
if (raw === null) return null;
try {
if (this.shouldEncrypt(key)) {
const decrypted = this.encryption.decrypt(raw);
if (!decrypted) return null;
return JSON.parse(decrypted) as T;
}
return JSON.parse(raw) as T;
} catch {
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`);
localStorage.removeItem(key);
return null;
}
}
async removeItem(key: string): Promise<void> {
localStorage.removeItem(key);
}
async clear(): Promise<void> {
localStorage.clear();
}
async hasItem(key: string): Promise<boolean> {
return localStorage.getItem(key) !== null;
}
async keys(): Promise<string[]> {
const result: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null) result.push(key);
}
return result;
}
}
@@ -0,0 +1,38 @@
/**
* Generic storage interface contract.
*
* All storage implementations (localStorage, IndexedDB) must
* conform to this interface. Methods use generics to enforce
* type-safe serialization/deserialization at the consumer level.
*
* @example
* ```ts
* const user = await storage.getItem<UserProfile>(StorageKey.USER_PROFILE);
* ```
*/
export interface IStorageService {
/**
* Persist a value under the given key.
* The value is JSON-serialized before storage.
* If encryption is enabled, the serialized payload is encrypted at rest.
*/
setItem<T>(key: string, value: T): Promise<void>;
/**
* Retrieve and deserialize a value by key.
* Returns `null` if the key does not exist or decryption/parsing fails.
*/
getItem<T>(key: string): Promise<T | null>;
/** Remove a single key from storage. */
removeItem(key: string): Promise<void>;
/** Remove all keys managed by this storage instance. */
clear(): Promise<void>;
/** Check if a key exists in storage. */
hasItem(key: string): Promise<boolean>;
/** Get all keys currently in storage. */
keys(): Promise<string[]>;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Centralized storage key registry.
*
* ALL keys used across the application MUST be registered here
* as string literal constants. This prevents key collisions,
* enables grep-ability, and provides a single source of truth
* for what data is persisted in the browser.
*
* Convention: `SCREAMING_SNAKE_CASE` for the constant,
* `kebab-case` or `snake_case` for the actual string value.
*
* @example
* ```ts
* await secureStorage.setItem(StorageKey.ACCESS_TOKEN, token);
* ```
*/
export const StorageKey = {
// ── Auth ─────────────────────────────────────────────────────
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
USER_PERMISSIONS: 'user_permissions',
// ── App Preferences ──────────────────────────────────────────
THEME: 'app_theme',
LOCALE: 'app_locale',
SIDEBAR_COLLAPSED: 'sidebar_collapsed',
// ── Session ──────────────────────────────────────────────────
FARO_SESSION: 'faroSession',
LAST_ACTIVE_ROUTE: 'last_active_route',
// ── Feature Flags / Cache ────────────────────────────────────
FEATURE_FLAGS: 'feature_flags',
CACHE_VERSION: 'cache_version',
} as const;
/** Union type of all registered storage key values. */
export type StorageKeyValue = (typeof StorageKey)[keyof typeof StorageKey];
/**
* Keys that require encryption at rest.
*
* Any key listed here will be automatically encrypted before
* writing to storage and decrypted on read. All other keys
* are stored as plain JSON.
*/
export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
StorageKey.ACCESS_TOKEN,
StorageKey.REFRESH_TOKEN,
StorageKey.USER_PROFILE,
StorageKey.USER_PERMISSIONS,
]);
+220
View File
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LocalStorageService } from './local-storage.service';
import { StorageKey, ENCRYPTED_KEYS } from './storage.key';
// ─── Mock @repo/utils EncryptionUtils ───────────────────────────
const mockEncrypt = vi.fn((data: string) => `ENC[${data}]`);
const mockDecrypt = vi.fn((data: string) => {
// Strip the ENC[] wrapper
const match = data.match(/^ENC\[(.+)\]$/);
return match ? match[1] : '';
});
const mockEncryptionUtils = {
encrypt: mockEncrypt,
decrypt: mockDecrypt,
};
// ─── Mock browser localStorage ──────────────────────────────────
const store: Record<string, string> = {};
const mockLocalStorage: Storage = {
getItem: vi.fn((key: string): string | null => store[key] ?? null),
setItem: vi.fn((key: string, value: string): void => { store[key] = value; }),
removeItem: vi.fn((key: string): void => { delete store[key]; }),
clear: vi.fn((): void => { for (const key of Object.keys(store)) delete store[key]; }),
get length() { return Object.keys(store).length; },
key(index: number): string | null { return Object.keys(store)[index] ?? null; },
};
// Install mock
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
});
// ─── Test Data ──────────────────────────────────────────────────
interface TestUser {
id: number;
name: string;
role: string;
}
const testUser: TestUser = { id: 1, name: 'Firman', role: 'admin' };
// ─── Tests ──────────────────────────────────────────────────────
describe('LocalStorageService', () => {
let storage: LocalStorageService;
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(store)) delete store[key];
// Pass mock encryption utils to avoid importing real crypto-js
storage = new LocalStorageService(mockEncryptionUtils as never);
});
// ── setItem / getItem ─────────────────────────────────────────
describe('setItem / getItem', () => {
it('stores and retrieves a plain object (non-encrypted key)', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
const result = await storage.getItem<string>(StorageKey.THEME);
expect(result).toBe('dark');
});
it('stores plain JSON without encryption for non-sensitive keys', async () => {
await storage.setItem(StorageKey.LOCALE, 'en-US');
expect(mockEncrypt).not.toHaveBeenCalled();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
StorageKey.LOCALE,
'"en-US"',
);
});
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
const token = 'eyJhbGciOiJIUzI1NiJ9.test';
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(token));
// The stored value should be the encrypted payload
expect(store[StorageKey.ACCESS_TOKEN]).toBe(`ENC[${JSON.stringify(token)}]`);
});
it('decrypts sensitive keys on read', async () => {
const token = 'secret_token_123';
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
expect(mockDecrypt).toHaveBeenCalled();
expect(result).toBe(token);
});
it('stores and retrieves complex objects with generics', async () => {
await storage.setItem(StorageKey.THEME, testUser);
const result = await storage.getItem<TestUser>(StorageKey.THEME);
expect(result).toEqual(testUser);
});
it('stores complex objects encrypted for sensitive keys', async () => {
await storage.setItem(StorageKey.USER_PROFILE, testUser);
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(testUser));
const result = await storage.getItem<TestUser>(StorageKey.USER_PROFILE);
expect(result).toEqual(testUser);
});
it('returns null for non-existent keys', async () => {
const result = await storage.getItem<string>('nonexistent');
expect(result).toBeNull();
});
it('handles corrupt/invalid JSON gracefully', async () => {
store[StorageKey.THEME] = '{invalid json';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await storage.getItem<string>(StorageKey.THEME);
expect(result).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse key'),
);
// Corrupt entry should be cleaned up
expect(store[StorageKey.THEME]).toBeUndefined();
warnSpy.mockRestore();
});
it('handles failed decryption gracefully', async () => {
// Write raw garbage to an encrypted key
store[StorageKey.ACCESS_TOKEN] = 'not-encrypted-data';
mockDecrypt.mockReturnValueOnce('');
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
expect(result).toBeNull();
});
});
// ── removeItem ────────────────────────────────────────────────
describe('removeItem', () => {
it('removes a key from storage', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
await storage.removeItem(StorageKey.THEME);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(StorageKey.THEME);
const result = await storage.getItem<string>(StorageKey.THEME);
expect(result).toBeNull();
});
});
// ── clear ─────────────────────────────────────────────────────
describe('clear', () => {
it('clears all keys from storage', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
await storage.setItem(StorageKey.LOCALE, 'en');
await storage.clear();
expect(mockLocalStorage.clear).toHaveBeenCalled();
expect(Object.keys(store)).toHaveLength(0);
});
});
// ── hasItem ───────────────────────────────────────────────────
describe('hasItem', () => {
it('returns true for existing keys', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
expect(await storage.hasItem(StorageKey.THEME)).toBe(true);
});
it('returns false for non-existent keys', async () => {
expect(await storage.hasItem('ghost_key')).toBe(false);
});
});
// ── keys ──────────────────────────────────────────────────────
describe('keys', () => {
it('returns all stored keys', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
await storage.setItem(StorageKey.LOCALE, 'en');
const allKeys = await storage.keys();
expect(allKeys).toContain(StorageKey.THEME);
expect(allKeys).toContain(StorageKey.LOCALE);
expect(allKeys).toHaveLength(2);
});
});
// ── Encryption Key Classification ─────────────────────────────
describe('encryption key classification', () => {
it('ACCESS_TOKEN is in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.ACCESS_TOKEN)).toBe(true);
});
it('REFRESH_TOKEN is in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.REFRESH_TOKEN)).toBe(true);
});
it('USER_PROFILE is in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.USER_PROFILE)).toBe(true);
});
it('THEME is NOT in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.THEME)).toBe(false);
});
it('LOCALE is NOT in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.LOCALE)).toBe(false);
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}
+652 -61
View File
File diff suppressed because it is too large Load Diff