Refactor code structure for improved readability and maintainability

This commit is contained in:
Firman Ramdhani
2026-05-22 17:53:35 +07:00
parent af0fe70ac6
commit 4260e240a0
37 changed files with 2915 additions and 74 deletions
+3
View File
@@ -41,3 +41,6 @@ storybook-static
out/ out/
release/ release/
web-dist/ web-dist/
# Legacy Code (if applicable)
legacy/
+37 -3
View File
@@ -33,6 +33,7 @@ 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
│ ├── 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 +205,40 @@ 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/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 +246,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
--- ---
### 6. `packages/ui` ### 7. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts). Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -221,7 +255,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
--- ---
### 7. `packages/configs` ### 8. `packages/configs`
Single source of truth for tooling configuration. Single source of truth for tooling configuration.
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/core-api": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"@repo/utils": "workspace:*", "@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
+5
View File
@@ -1,5 +1,6 @@
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';
export default function App() { export default function App() {
return ( return (
@@ -31,6 +32,10 @@ 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> </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,
});
+14
View File
@@ -1,3 +1,17 @@
// ─── 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';
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1
View File
@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/core-api": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"@repo/utils": "workspace:*", "@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
+2 -1
View File
@@ -33,7 +33,8 @@ export default function App() {
<Route path="/403" element={<Forbidden />} /> <Route path="/403" element={<Forbidden />} />
<Route path="/maintenance" element={<Maintenance />} /> <Route path="/maintenance" element={<Maintenance />} />
<Route path="/coming-soon" element={<ComingSoon />} /> <Route path="/coming-soon" element={<ComingSoon />} />
<Route path="/" element={<Navigate to="/showcase" />} /> {/* <Route path="/" element={<Navigate to="/showcase" />} /> */}
<Route path="/" element={<Navigate to="/app" />} />
<Route path="*" element={<Navigate to="/404" />} /> <Route path="*" element={<Navigate to="/404" />} />
</Routes> </Routes>
</Suspense> </Suspense>
@@ -1,3 +1,10 @@
import BookingSample from "./features/booking/presentation/BookingSample";
export default function ExamplePage() { export default function ExamplePage() {
return <div className="bg-amber-200">example</div>; 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>;
} }
@@ -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>
);
}
+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;
},
},
);
+14 -7
View File
@@ -1,15 +1,22 @@
// 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';
const App = lazy(() => import('./apps')); const App = lazy(() => import('./apps'));
// DateUtils.setGlobalConfig('Asia/Jakarta');
// DateUtils.setGlobalConfig('Asia/Makassar');
// CurrencyUtils.setGlobalPrefix('IDR ');
// CurrencyUtils.setGlobalDecimalSeparator(',');
createRoot(document.getElementById('app')!).render( createRoot(document.getElementById('app')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+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,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,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
}
}
+534 -61
View File
@@ -96,10 +96,13 @@ importers:
version: 5.5.4 version: 5.5.4
vite: vite:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.4.17 version: 5.4.17(@types/node@22.19.3)
apps/landing: apps/landing:
dependencies: dependencies:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
'@repo/ui': '@repo/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
@@ -142,10 +145,13 @@ importers:
version: 5.5.4 version: 5.5.4
vite: vite:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.4.17 version: 5.4.17(@types/node@22.19.3)
apps/web: apps/web:
dependencies: dependencies:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
'@repo/ui': '@repo/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
@@ -194,10 +200,10 @@ importers:
version: 5.5.4 version: 5.5.4
vite: vite:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.4.17 version: 5.4.17(@types/node@22.19.3)
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17 version: 4.0.17(@opentelemetry/api@1.9.1)
packages/configs/eslint: packages/configs/eslint:
dependencies: dependencies:
@@ -236,6 +242,46 @@ importers:
specifier: ^8.57.0 specifier: ^8.57.0
version: 8.57.1 version: 8.57.1
packages/core-api:
dependencies:
'@grafana/faro-react':
specifier: ^2.1.0
version: 2.7.0(react@19.2.3)
'@grafana/faro-web-sdk':
specifier: ^2.1.0
version: 2.7.0
'@grafana/faro-web-tracing':
specifier: ^2.1.0
version: 2.7.0
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.1
'@opentelemetry/exporter-trace-otlp-http':
specifier: ^0.213.0
version: 0.213.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-web':
specifier: ^2.2.0
version: 2.7.1(@opentelemetry/api@1.9.1)
axios:
specifier: ^1.9.0
version: 1.16.1
devDependencies:
'@repo/eslint-config':
specifier: workspace:*
version: link:../configs/eslint
'@repo/typescript-config':
specifier: workspace:*
version: link:../configs/typescript
eslint:
specifier: ^8.57.1
version: 8.57.1
typescript:
specifier: 5.5.4
version: 5.5.4
vitest:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/ui: packages/ui:
dependencies: dependencies:
'@mantine/core': '@mantine/core':
@@ -292,10 +338,10 @@ importers:
version: 5.5.4 version: 5.5.4
vite: vite:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.4.17 version: 5.4.17(@types/node@22.19.3)
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17 version: 4.0.17(@opentelemetry/api@1.9.1)
packages/utils: packages/utils:
dependencies: dependencies:
@@ -323,7 +369,7 @@ importers:
version: 5.5.4 version: 5.5.4
vitest: vitest:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17 version: 4.0.17(@opentelemetry/api@1.9.1)
packages: packages:
@@ -1156,6 +1202,62 @@ packages:
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
dev: true dev: true
/@grafana/faro-core@2.7.0:
resolution: {integrity: sha512-fNOfWEouoHhZ4dw+oLO+UGoMBLgiLLQU56dp9+NgO3mGNwCYsFTQuKgKQfOe5DDmAbwpRISEyLbiMkEyY7mIJQ==}
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
dev: false
/@grafana/faro-react@2.7.0(react@19.2.3):
resolution: {integrity: sha512-95wmypcDjHdGe64TCivq3DKLut9q3RRANgKoSLSQhGHmXdKTwE2Ov0+UdUkVQ9uu3KsGGQJRuXkldP0YMIEMiw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-router: ^7.12.0
react-router-dom: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
react-dom:
optional: true
react-router:
optional: true
react-router-dom:
optional: true
dependencies:
'@grafana/faro-web-sdk': 2.7.0
'@grafana/faro-web-tracing': 2.7.0
hoist-non-react-statics: 3.3.2
react: 19.2.3
transitivePeerDependencies:
- supports-color
dev: false
/@grafana/faro-web-sdk@2.7.0:
resolution: {integrity: sha512-NIbFK2g634/KOcq6ej1LseZrbbRejBiIrO8beQpYX6CkyXVGrcdoWRfJdqkQ3Ys60h4aTH+xoAh2Oh0HebhOZg==}
dependencies:
'@grafana/faro-core': 2.7.0
ua-parser-js: 1.0.41
web-vitals: 5.2.0
dev: false
/@grafana/faro-web-tracing@2.7.0:
resolution: {integrity: sha512-k6eIJeLwm60FpCB+OevL+VLqnoibtCsnozs3ZCQpiUMt6G2PUIQ6/wkHfSEF46R2H+AuVB3nk9oHUKpS5o2wQQ==}
dependencies:
'@grafana/faro-web-sdk': 2.7.0
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-http': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-fetch': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-xml-http-request': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
transitivePeerDependencies:
- supports-color
dev: false
/@humanwhocodes/config-array@0.13.0: /@humanwhocodes/config-array@0.13.0:
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@@ -1197,7 +1299,7 @@ packages:
magic-string: 0.27.0 magic-string: 0.27.0
react-docgen-typescript: 2.4.0(typescript@5.5.4) react-docgen-typescript: 2.4.0(typescript@5.5.4)
typescript: 5.5.4 typescript: 5.5.4
vite: 5.4.17 vite: 5.4.17(@types/node@22.19.3)
dev: true dev: true
/@jridgewell/gen-mapping@0.3.13: /@jridgewell/gen-mapping@0.3.13:
@@ -1443,6 +1545,279 @@ packages:
which: 4.0.0 which: 4.0.0
dev: false dev: false
/@opentelemetry/api-logs@0.213.0:
resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==}
engines: {node: '>=8.0.0'}
dependencies:
'@opentelemetry/api': 1.9.1
dev: false
/@opentelemetry/api-logs@0.218.0:
resolution: {integrity: sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==}
engines: {node: '>=8.0.0'}
dependencies:
'@opentelemetry/api': 1.9.1
dev: false
/@opentelemetry/api@1.9.1:
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
/@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/exporter-trace-otlp-http@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/instrumentation-fetch@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-eP/Y5hDupb+6MwZSaMw4ZdsDz8YgfJbJ4Ta86BMVeOmI2EArwXcd0v1nfNIvUzXTPi7nakidwqeuUa3FwRwECg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
transitivePeerDependencies:
- supports-color
dev: false
/@opentelemetry/instrumentation-xml-http-request@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-KzqSO62lTiqbT1ihKbbdJSt6A3TngIkacHJHi0SNUJmlpS0/Jg8Sn8kneKffQGjjpScODjZJ6LLs640zWlOGIg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-web': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
transitivePeerDependencies:
- supports-color
dev: false
/@opentelemetry/instrumentation@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.218.0
import-in-the-middle: 3.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
dev: false
/@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/otlp-exporter-base@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.213.0
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1)
protobufjs: 7.6.1
dev: false
/@opentelemetry/otlp-transformer@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.218.0
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.213.0
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/sdk-logs@0.218.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.218.0
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.41.1
dev: false
/@opentelemetry/sdk-trace-web@2.7.1(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-K806OouCSOjMd8Nr7+ZCq3QT22tdAzzS/7h8vprfiKjkgFQ99/dvwU8d12WJANA6D5Qtme65hyBAqAu9CkQuxQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
dev: false
/@opentelemetry/semantic-conventions@1.41.1:
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
dev: false
/@pkgjs/parseargs@0.11.0: /@pkgjs/parseargs@0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1454,6 +1829,48 @@ packages:
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
dev: false dev: false
/@protobufjs/aspromise@1.1.2:
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
dev: false
/@protobufjs/base64@1.1.2:
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
dev: false
/@protobufjs/codegen@2.0.5:
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
dev: false
/@protobufjs/eventemitter@1.1.1:
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
dev: false
/@protobufjs/fetch@1.1.1:
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
dependencies:
'@protobufjs/aspromise': 1.1.2
dev: false
/@protobufjs/float@1.0.2:
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
dev: false
/@protobufjs/inquire@1.1.2:
resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==}
dev: false
/@protobufjs/path@1.1.2:
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
dev: false
/@protobufjs/pool@1.1.0:
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
dev: false
/@protobufjs/utf8@1.1.1:
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
dev: false
/@rolldown/pluginutils@1.0.0-beta.53: /@rolldown/pluginutils@1.0.0-beta.53:
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
dev: true dev: true
@@ -2004,7 +2421,7 @@ packages:
browser-assert: 1.2.1 browser-assert: 1.2.1
storybook: 8.6.15(prettier@3.5.3) storybook: 8.6.15(prettier@3.5.3)
ts-dedent: 2.2.0 ts-dedent: 2.2.0
vite: 5.4.17 vite: 5.4.17(@types/node@22.19.3)
dev: true dev: true
/@storybook/components@8.6.15(storybook@8.6.15): /@storybook/components@8.6.15(storybook@8.6.15):
@@ -2146,7 +2563,7 @@ packages:
resolve: 1.22.11 resolve: 1.22.11
storybook: 8.6.15(prettier@3.5.3) storybook: 8.6.15(prettier@3.5.3)
tsconfig-paths: 4.2.0 tsconfig-paths: 4.2.0
vite: 5.4.17 vite: 5.4.17(@types/node@22.19.3)
transitivePeerDependencies: transitivePeerDependencies:
- rollup - rollup
- supports-color - supports-color
@@ -2333,7 +2750,7 @@ packages:
'@tailwindcss/node': 4.1.18 '@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18 '@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18 tailwindcss: 4.1.18
vite: 5.4.17 vite: 5.4.17(@types/node@22.19.3)
/@tootallnate/once@2.0.0: /@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
@@ -3094,7 +3511,7 @@ packages:
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.18.0 react-refresh: 0.18.0
vite: 5.4.17 vite: 5.4.17(@types/node@22.19.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
@@ -3178,6 +3595,14 @@ packages:
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dev: false dev: false
/acorn-import-attributes@1.9.5(acorn@8.15.0):
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
peerDependencies:
acorn: ^8
dependencies:
acorn: 8.15.0
dev: false
/acorn-jsx@5.3.2(acorn@8.14.1): /acorn-jsx@5.3.2(acorn@8.14.1):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -3209,7 +3634,6 @@ packages:
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true
/agent-base@7.1.4: /agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
@@ -3546,7 +3970,6 @@ packages:
/asynckit@0.4.0: /asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
/at-least-node@1.0.0: /at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
@@ -3564,6 +3987,18 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
dev: false dev: false
/axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
dev: false
/axobject-query@4.1.0: /axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3894,6 +4329,10 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dev: false dev: false
/cjs-module-lexer@2.2.0:
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
dev: false
/clean-regexp@1.0.0: /clean-regexp@1.0.0:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3986,7 +4425,6 @@ packages:
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
dev: true
/commander@5.1.0: /commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
@@ -4249,7 +4687,6 @@ packages:
/delayed-stream@1.0.0: /delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
dev: true
/delegates@1.0.0: /delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
@@ -5385,6 +5822,16 @@ packages:
/flatted@3.3.3: /flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
/follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dev: false
/for-each@0.3.5: /for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5407,7 +5854,6 @@ packages:
es-set-tostringtag: 2.1.0 es-set-tostringtag: 2.1.0
hasown: 2.0.2 hasown: 2.0.2
mime-types: 2.1.18 mime-types: 2.1.18
dev: true
/fs-constants@1.0.0: /fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -5723,6 +6169,12 @@ packages:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
react-is: 16.13.1
dev: false
/hosted-git-info@2.8.9: /hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
dev: false dev: false
@@ -5782,7 +6234,6 @@ packages:
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true
/https-proxy-agent@7.0.6: /https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
@@ -5844,6 +6295,16 @@ packages:
parent-module: 1.0.1 parent-module: 1.0.1
resolve-from: 4.0.0 resolve-from: 4.0.0
/import-in-the-middle@3.0.1:
resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==}
engines: {node: '>=18'}
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
cjs-module-lexer: 2.2.0
module-details-from-path: 1.0.4
dev: false
/import-meta-resolve@4.2.0: /import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
dev: false dev: false
@@ -6540,6 +7001,10 @@ packages:
is-unicode-supported: 0.1.0 is-unicode-supported: 0.1.0
dev: true dev: true
/long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
dev: false
/longest-streak@3.1.0: /longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
dev: false dev: false
@@ -7021,7 +7486,6 @@ packages:
/mime-db@1.33.0: /mime-db@1.33.0:
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
dev: true
/mime-db@1.54.0: /mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
@@ -7033,7 +7497,6 @@ packages:
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
dependencies: dependencies:
mime-db: 1.33.0 mime-db: 1.33.0
dev: true
/mime@2.6.0: /mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
@@ -7165,6 +7628,10 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
dev: false
/mri@1.2.0: /mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -7712,6 +8179,30 @@ packages:
react-is: 16.13.1 react-is: 16.13.1
dev: false dev: false
/protobufjs@7.6.1:
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
engines: {node: '>=12.0.0'}
requiresBuild: true
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@types/node': 22.19.3
long: 5.3.2
dev: false
/proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
dev: false
/pump@3.0.4: /pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
dependencies: dependencies:
@@ -8059,6 +8550,16 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
dev: false
/requireindex@1.2.0: /requireindex@1.2.0:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'} engines: {node: '>=0.10.5'}
@@ -9111,6 +9612,11 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
/ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
dev: false
/unbox-primitive@1.1.0: /unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -9457,43 +9963,6 @@ packages:
vfile-message: 4.0.3 vfile-message: 4.0.3
dev: false dev: false
/vite@5.4.17:
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.39.0
optionalDependencies:
fsevents: 2.3.3
/vite@5.4.17(@types/node@22.19.3): /vite@5.4.17(@types/node@22.19.3):
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==} resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -9527,11 +9996,10 @@ packages:
dependencies: dependencies:
'@types/node': 22.19.3 '@types/node': 22.19.3
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.6 postcss: 8.5.3
rollup: 4.55.1 rollup: 4.39.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
dev: true
/vite@7.3.1: /vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
@@ -9583,7 +10051,7 @@ packages:
fsevents: 2.3.3 fsevents: 2.3.3
dev: true dev: true
/vitest@4.0.17: /vitest@4.0.17(@opentelemetry/api@1.9.1):
resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true hasBin: true
@@ -9617,6 +10085,7 @@ packages:
jsdom: jsdom:
optional: true optional: true
dependencies: dependencies:
'@opentelemetry/api': 1.9.1
'@vitest/expect': 4.0.17 '@vitest/expect': 4.0.17
'@vitest/mocker': 4.0.17(vite@7.3.1) '@vitest/mocker': 4.0.17(vite@7.3.1)
'@vitest/pretty-format': 4.0.17 '@vitest/pretty-format': 4.0.17
@@ -9661,6 +10130,10 @@ packages:
defaults: 1.0.4 defaults: 1.0.4
dev: true dev: true
/web-vitals@5.2.0:
resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
dev: false
/webpack-virtual-modules@0.6.2: /webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
dev: true dev: true