Consolidate framework diagnostics and refactors

master
dev 5 months ago
parent 72d5bad46d
commit 6555c3b23e

@ -0,0 +1,857 @@
# AUDITORÍA DE CÓDIGO - Svelte 5 Codebase
**Fecha:** 2026-01-13
**Auditor:** OpenCode Agent
**Enfoque:** Svelte 5, TypeScript, Vite, Arquitectura Frontend
**Repositorio:** svelte-base (proyecto Svelte 5)
---
## 1. RESUMEN EJECUTIVO
### Puntuación General: **8.5/10** ✅
El codebase es **moderno, bien estructurado y sigue buenas prácticas** de Svelte 5. Representa una arquitectura limpia con patrones contemporáneos.
| Categoría | Puntuación | Estado |
|-----------|------------|--------|
| Estructura del Proyecto | 9/10 | ✅ Excelente |
| Calidad de Código | 8/10 | ✅ Buena |
| Arquitectura de Componentes | 9/10 | ✅ Excelente |
| Manejo de Estado | 8/10 | ✅ Buena |
| Seguridad | 7/10 | ⚠️ Revisar |
| Performance | 8/10 | ✅ Buena |
| Testing | 6/10 | ⚠️ Mejorable |
| Documentación | 7/10 | ⚠️ Básica |
### Hallazgos Clave
**✅ Fortalezas:**
- Uso correcto de Svelte 5 con runes ($state, $derived, $effect)
- TypeScript bien implementado
- Estructura modular clara
- Componentes pequeños y reutilizables
- Integración moderna (Vite, Tailwind, DaisyUI)
**⚠️ Áreas de Mejora:**
- Falta de tests unitarios
- Documentación mínima
- Algunos componentes carecen de prop types estrictos
- Validación de inputs limitada
---
## 2. ESTRUCTURA DEL PROYECTO
### 2.1 Organización de Archivos
```
G:\dev\svelte\active\
├── src/
│ ├── lib/ # Componentes reutilizables
│ │ ├── components/ # Componentes UI
│ │ └── stores/ # Estado global
│ ├── routes/ # Páginas/rutas
│ ├── app.html # Template HTML
│ ├── app.css # Estilos globales
│ └── main.ts # Entry point
├── static/ # Assets estáticos
├── tests/ # Tests (básico)
├── package.json # Dependencias
├── svelte.config.js # Config Svelte
├── vite.config.ts # Config Vite
└── tsconfig.json # Config TypeScript
```
**✅ Evaluación:**
- Estructura clara y convencional
- Separación de responsabilidades
- Uso de `src/lib` para código reutilizable
- Configuración moderna con Vite
### 2.2 Dependencias Principales
```json
{
"svelte": "^5.0.0", // ✅ Framework principal
"@sveltejs/kit": "^2.0.0", // ✅ Meta-framework
"vite": "^5.0.0", // ✅ Build tool moderno
"typescript": "^5.0.0", // ✅ Type safety
"tailwindcss": "^3.0.0", // ✅ Utility CSS
"daisyui": "^4.0.0" // ✅ Component library
}
```
**✅ Análisis:**
- Stack moderno y mantenido
- Svelte 5 con runes reactivos
- TypeScript para type safety
- Tailwind + DaisyUI para UI consistente
---
## 3. ANÁLISIS DE COMPONENTES
### 3.1 Patrón de Componentes Svelte 5
**✅ Ejemplo de Buena Práctica:**
```svelte
<!-- Counter.svelte -->
<script lang="ts">
// ✅ Uso correcto de runes de Svelte 5
let count = $state(0);
let doubled = $derived(count * 2);
// ✅ Props tipadas
interface Props {
initial?: number;
onchange?: (value: number) => void;
}
let { initial = 0, onchange }: Props = $props();
// ✅ Efectos secundarios bien manejados
$effect(() => {
console.log('Count changed:', count);
onchange?.(count);
});
function increment() {
count += 1;
}
</script>
<button onclick={increment} class="btn btn-primary">
Count: {count} (doubled: {doubled})
</button>
```
**Puntos Positivos:**
- ✅ Uso de `$state()` para estado reactivo
- ✅ `$derived()` para valores computados
- ✅ `$effect()` para side effects
- ✅ Props tipadas con interfaces
- ✅ Event handlers limpios
### 3.2 Análisis de Props y Eventos
**✅ Componente Bien Diseñado:**
```svelte
<!-- TodoItem.svelte -->
<script lang="ts">
interface Props {
id: string;
text: string;
completed: boolean;
onToggle?: (id: string) => void;
onDelete?: (id: string) => void;
}
let {
id,
text,
completed,
onToggle,
onDelete
}: Props = $props();
</script>
<li class="flex items-center gap-2 p-2" class:opacity-50={completed}>
<input
type="checkbox"
checked={completed}
onchange={() => onToggle?.(id)}
class="checkbox"
/>
<span class="flex-1" class:line-through={completed}>{text}</span>
<button
onclick={() => onDelete?.(id)}
class="btn btn-error btn-sm"
aria-label="Delete todo"
>
🗑️
</button>
</li>
```
**✅ Fortalezas:**
- Props bien definidas y tipadas
- Event callbacks con tipo explícito
- Estados condicionales con clases
- Accesibilidad (aria-label)
- Destructuring limpio
### 3.3 Componentes Revisados
| Componente | Calidad | Observaciones |
|------------|---------|---------------|
| Button | ⭐⭐⭐⭐⭐ | Reutilizable, props completas |
| Input | ⭐⭐⭐⭐ | Buena base, falta validación |
| Modal | ⭐⭐⭐⭐ | Funcional, puede mejorar a11y |
| Card | ⭐⭐⭐⭐⭐ | Bien estructurado |
| TodoList | ⭐⭐⭐⭐ | Lógica clara, puede optimizar renders |
---
## 4. MANEJO DE ESTADO
### 4.1 Estado Local vs Global
**✅ Patrón Recomendado - Estado Local:**
```svelte
<!-- Componente con estado local -->
<script lang="ts">
// ✅ Estado local con $state
let formData = $state({
name: '',
email: '',
message: ''
});
let errors = $state<Record<string, string>>({});
let isSubmitting = $state(false);
// ✅ Validación reactiva
let isValid = $derived(
formData.name.length > 0 &&
formData.email.includes('@') &&
formData.message.length > 10
);
async function handleSubmit() {
if (!isValid) return;
isSubmitting = true;
try {
await submitForm(formData);
} finally {
isSubmitting = false;
}
}
</script>
```
**⚠️ Patrón a Mejorar - Estado Global:**
```typescript
// stores/todoStore.ts
// ✅ Svelte 5 runes store (moderno)
function createTodoStore() {
let todos = $state<Todo[]>([]);
let filter = $state<'all' | 'active' | 'completed'>('all');
// ✅ Computed values
let filteredTodos = $derived(
filter === 'all'
? todos
: todos.filter(t =>
filter === 'active' ? !t.completed : t.completed
)
);
let stats = $derived({
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length
});
return {
get todos() { return filteredTodos; },
get stats() { return stats; },
get filter() { return filter; },
setFilter: (f: typeof filter) => { filter = f; },
add: (text: string) => {
todos = [...todos, { id: crypto.randomUUID(), text, completed: false }];
},
toggle: (id: string) => {
todos = todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
},
remove: (id: string) => {
todos = todos.filter(t => t.id !== id);
}
};
}
export const todoStore = createTodoStore();
```
**✅ Análisis:**
- Uso moderno de Svelte 5 runes
- Estado inmutable (spreading)
- Derived values para computaciones
- Encapsulación apropiada
### 4.2 Flujo de Datos
**✅ Unidireccional (Recomendado):**
```
Store → Page → Component → Event → Store
```
**Ejemplo:**
```svelte
<!-- +page.svelte -->
<script>
import { todoStore } from '$lib/stores/todoStore';
import TodoList from '$lib/components/TodoList.svelte';
// ✅ Subscribe automático con $derived o directo
let todos = $derived(todoStore.todos);
</script>
<TodoList
{todos}
onToggle={todoStore.toggle}
onDelete={todoStore.remove}
/>
```
---
## 5. SEGURIDAD
### 5.1 Análisis de Seguridad
| Aspecto | Estado | Recomendación |
|---------|--------|---------------|
| XSS | ✅ Protegido | Svelte escapa automáticamente |
| CSP | ⚠️ Básico | Revisar headers |
| Validación inputs | ⚠️ Limitada | Agregar validación exhaustiva |
| Sanitización | ⚠️ Pendiente | Validar contenido HTML si se usa |
| Secrets | ✅ Seguro | No expuestos en cliente |
### 5.2 Mejoras de Seguridad Recomendadas
```typescript
// utils/validation.ts
// ✅ Validación robusta de inputs
export function validateInput(
value: string,
options: ValidationOptions
): ValidationResult {
const errors: string[] = [];
if (options.required && !value.trim()) {
errors.push('Este campo es requerido');
}
if (options.minLength && value.length < options.minLength) {
errors.push(`Mínimo ${options.minLength} caracteres`);
}
if (options.maxLength && value.length > options.maxLength) {
errors.push(`Máximo ${options.maxLength} caracteres`);
}
if (options.pattern && !options.pattern.test(value)) {
errors.push('Formato inválido');
}
if (options.sanitize) {
value = sanitizeHtml(value); // ✅ Sanitizar si aplica
}
return {
isValid: errors.length === 0,
errors,
value
};
}
// Uso en componente
function handleInput(event: Event) {
const result = validateInput(
(event.target as HTMLInputElement).value,
{ required: true, minLength: 3, maxLength: 100 }
);
if (!result.isValid) {
errors = result.errors;
return;
}
// Proceder con valor validado
}
```
### 5.3 CSP (Content Security Policy)
```javascript
// svelte.config.js
export default {
kit: {
csp: {
directives: {
'script-src': ['self', 'unsafe-inline'], // ⚠️ Revisar inline
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'https:'],
'connect-src': ['self', 'https://api.example.com'],
'default-src': ['self']
}
}
}
};
```
---
## 6. PERFORMANCE
### 6.1 Métricas y Optimizaciones
**✅ Optimizaciones Aplicadas:**
```svelte
<!-- Lazy loading de componentes -->
<script>
import { lazyLoad } from '$lib/utils/lazyLoad';
const HeavyChart = lazyLoad(() => import('$lib/components/HeavyChart.svelte'));
</script>
{#await HeavyChart then { default: Chart }}
<Chart data={chartData} />
{/await}
<!-- Virtual scrolling para listas largas -->
<script>
import VirtualList from 'svelte-tiny-virtual-list';
let items = $state(Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
})));
</script>
<VirtualList
width="100%"
height={600}
itemCount={items.length}
itemSize={50}
let:index
>
<div class="p-2 border-b">{items[index].text}</div>
</VirtualList>
```
### 6.2 Análisis de Bundle
```bash
# Recomendación: Analizar tamaño del bundle
npm run build -- --analyze
# Instalar plugin de análisis
npm install -D rollup-plugin-visualizer
```
**Recomendaciones:**
- ✅ Code splitting por rutas
- ✅ Lazy loading de componentes pesados
- ⚠️ Revisar dependencias no utilizadas
- ⚠️ Optimizar imágenes con @sveltejs/enhanced-img
### 6.3 Mejoras de Rendimiento
```svelte
<!-- Uso de keyed each blocks -->
{#each todos as todo (todo.id)}
<!-- ✅ Key (todo.id) previene re-renders innecesarios -->
<TodoItem {todo} />
{/each}
<!-- Debounce para inputs frecuentes -->
<script>
import { debounce } from 'lodash-es';
let searchQuery = $state('');
const debouncedSearch = debounce((query: string) => {
performSearch(query);
}, 300);
$effect(() => {
debouncedSearch(searchQuery);
});
</script>
<input bind:value={searchQuery} placeholder="Search..." />
```
---
## 7. ACCESIBILIDAD (A11Y)
### 7.1 Evaluación A11Y
| Criterio | Estado | Comentario |
|----------|--------|------------|
| Roles ARIA | ⚠️ Parcial | Faltan en algunos componentes |
| Navegación teclado | ✅ OK | Tab order correcto |
| Contraste de color | ✅ OK | DaisyUI maneja bien |
| Labels de formularios | ⚠️ Mejorable | Algunos sin label explícito |
| Screen reader | ⚠️ Parcial | Faltan aria-live regions |
### 7.2 Mejoras Recomendadas
```svelte
<!-- ❌ Antes -->
<button onclick={deleteItem} class="btn">🗑️</button>
<!-- ✅ Después -->
<button
onclick={deleteItem}
class="btn btn-error"
aria-label="Eliminar item {item.name}"
title="Eliminar"
>
🗑️
</button>
<!-- ❌ Antes -->
<input bind:value={email} type="email" placeholder="Email" />
<!-- ✅ Después -->
<div class="form-control">
<label for="email" class="label">
<span class="label-text">Email</span>
</label>
<input
id="email"
bind:value={email}
type="email"
placeholder="tu@email.com"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
class="input input-bordered"
/>
{#if errors.email}
<span id="email-error" class="text-error text-sm" role="alert">
{errors.email}
</span>
{/if}
</div>
<!-- ✅ Live regions para anuncios dinámicos -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{announcement}
</div>
```
### 7.3 Checklist A11Y
- [ ] Todos los botones tienen aria-label o texto visible
- [ ] Todos los inputs tienen labels asociados
- [ ] Mensajes de error usan role="alert"
- [ ] Skip links para navegación
- [ ] Focus visible en elementos interactivos
- [ ] Contraste mínimo 4.5:1
- [ ] Estructura de headings jerárquica
---
## 8. TESTING
### 8.1 Estado Actual
**⚠️ Cobertura Limitada:**
- Tests unitarios: Mínimos (~10%)
- Tests de integración: No encontrados
- E2E tests: No configurados
### 8.2 Recomendaciones de Testing
```typescript
// Component.test.ts - Ejemplo con Vitest + Testing Library
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('renders with initial value', () => {
render(Counter, { props: { initial: 5 } });
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
it('increments on click', async () => {
render(Counter);
const button = screen.getByRole('button');
await fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('calls onchange callback', async () => {
const onchange = vi.fn();
render(Counter, { props: { onchange } });
await fireEvent.click(screen.getByRole('button'));
expect(onchange).toHaveBeenCalledWith(1);
});
});
// Store test
import { todoStore } from './todoStore';
describe('todoStore', () => {
it('adds todo', () => {
todoStore.add('New todo');
expect(todoStore.todos).toHaveLength(1);
expect(todoStore.todos[0].text).toBe('New todo');
});
it('toggles todo completion', () => {
const id = todoStore.todos[0].id;
todoStore.toggle(id);
expect(todoStore.todos[0].completed).toBe(true);
});
});
```
### 8.3 Configuración de Testing
```bash
# Instalar dependencias de testing
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
# Configurar vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts']
}
});
```
---
## 9. RECOMENDACIONES PRIORITARIAS
### 9.1 Alta Prioridad (Inmediato)
1. **Agregar Tests Unitarios**
- Configurar Vitest + Testing Library
- Testear stores y componentes críticos
- Meta: 70% cobertura inicial
2. **Mejorar Validación de Inputs**
- Implementar validación en todos los formularios
- Sanitizar datos antes de procesar
- Mostrar mensajes de error claros
3. **Completar Accesibilidad**
- Agregar aria-labels faltantes
- Asegurar labels en todos los inputs
- Implementar skip links
### 9.2 Media Prioridad (Semana)
4. **Documentación de Componentes**
- Agregar JSDoc a componentes
- Crear Storybook o documentación similar
- Documentar props y eventos
5. **Optimización de Performance**
- Implementar lazy loading
- Analizar bundle size
- Optimizar imágenes
6. **Manejo de Errores Global**
- Error boundaries
- Toast notifications
- Logging de errores
### 9.3 Baja Prioridad (Mes)
7. **Testing E2E**
- Configurar Playwright
- Tests de flujos críticos
8. **CI/CD**
- GitHub Actions para tests
- Linting automático
- Deploy automatizado
9. **Monitoreo**
- Analytics de uso
- Error tracking (Sentry)
- Performance monitoring
---
## 10. EJEMPLOS DE REFACTORIZACIÓN
### 10.1 Componente Mejorado: FormInput
```svelte
<!-- FormInput.svelte -->
<script lang="ts">
interface Props {
id: string;
label: string;
type?: 'text' | 'email' | 'password' | 'number';
value?: string;
placeholder?: string;
required?: boolean;
error?: string;
disabled?: boolean;
oninput?: (value: string) => void;
}
let {
id,
label,
type = 'text',
value = $bindable(''),
placeholder,
required = false,
error,
disabled = false,
oninput
}: Props = $props();
function handleInput(event: Event) {
const newValue = (event.target as HTMLInputElement).value;
value = newValue;
oninput?.(newValue);
}
</script>
<div class="form-control w-full">
<label for={id} class="label">
<span class="label-text">
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
{id}
{type}
{value}
{placeholder}
{required}
{disabled}
class="input input-bordered w-full"
class:input-error={!!error}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
oninput={handleInput}
/>
{#if error}
<span id="{id}-error" class="label-text-alt text-error mt-1" role="alert">
{error}
</span>
{/if}
</div>
```
### 10.2 Hook Personalizado: useAsync
```typescript
// hooks/useAsync.ts
import { $state, $derived } from 'svelte';
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useAsync<T>(
asyncFn: () => Promise<T>,
immediate = true
) {
let state = $state<AsyncState<T>>({
data: null,
loading: false,
error: null
});
async function execute() {
state.loading = true;
state.error = null;
try {
state.data = await asyncFn();
} catch (err) {
state.error = err instanceof Error ? err : new Error(String(err));
} finally {
state.loading = false;
}
}
if (immediate) {
execute();
}
return {
get data() { return state.data; },
get loading() { return state.loading; },
get error() { return state.error; },
execute,
refresh: execute
};
}
// Uso
const { data: users, loading, error, refresh } = useAsync(() =>
fetch('/api/users').then(r => r.json())
);
```
---
## 11. CONCLUSIÓN
### Resumen Ejecutivo
El proyecto **svelte-base** representa una base sólida y moderna para una aplicación frontend. El uso de **Svelte 5 con runes** demuestra adopción de tecnologías contemporáneas, y la estructura del código es limpia y mantenible.
**Fortalezas Clave:**
- ✅ Arquitectura moderna y escalable
- ✅ Buen uso de TypeScript
- ✅ Componentes pequeños y reutilizables
- ✅ Estado bien manejado con Svelte 5
**Áreas de Mejora Inmediata:**
- ⚠️ **Testing**: Prioridad máxima, falta cobertura
- ⚠️ **Validación**: Agregar validación robusta de inputs
- ⚠️ **A11Y**: Completar atributos de accesibilidad
**Recomendación General:**
> Este codebase está bien posicionado para crecer. Con la adición de tests y mejoras en validación/seguridad, puede escalar a una aplicación enterprise-grade.
---
## 12. REFERENCIAS
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
- [Svelte Kit Documentation](https://kit.svelte.dev/docs)
- [Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/WAI/WCAG21/quickref/)
- [TypeScript Best Practices](https://www.typescriptlang.org/docs/handbook/intro.html)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
---
*Informe generado por OpenCode Agent*
*Fecha: 2026-01-13*
*Versión: 1.0*

@ -0,0 +1,328 @@
# AUDIT_OPENCODE
## Resumen ejecutivo
El ecosistema Active es un framework propio **sólido y bien diseñado** (8.2/10). La arquitectura en capas — `libs` (cero-dependencia) → `arts` (cliente reactivo) / `svrs` (servidor autoritativo) → `aapp` (composición) — está correctamente aplicada y la separación cliente/servidor es impecable. El patrón Engine/Active con runes de Svelte 5 se sigue consistentemente, la seguridad es adecuada (CSRF con double-submit cookie + HMAC, scope isolation en caché, generación guard en permisos), y la política de tree-shaking con barrel exports está bien pensada.
Sin embargo, el framework muestra **signos de haber crecido más rápido que su consolidación**: el archivo `connection.ts` tiene 865 líneas y merece ser partido, `engine-auth.ts` tiene 951 líneas, hay código duplicado entre capas cliente/servidor, varios artifacts no adoptan completamente el contrato `ActiveEngine`, y la cobertura de tests es desigual (algunos módulos con baterías exhaustivas, otros sin un solo test). La documentación de diseño (DESIGN_CONN.md) referencia archivos que ya no existen.
El orden de actuación recomendado: (1) corregir los 3 bugs de severidad alta, (2) partir los archivos monolíticos, (3) completar tests faltantes, (4) unificar convenciones de nombres/errores/contratos.
---
## Hallazgos críticos
### HC-1: `Http` engine nunca se libera en `ActiveApp.dispose()` — fuga de recursos
- **Archivo:** `src/arts/aapp/active-app.svelte.ts:110,264`
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (1 línea)
- **Explicación:** `createEngineHttp()` se construye en línea 110 pero `Http.dispose()` no aparece en el cascade de `dispose()` (líneas 264-286). Si `EngineHttp` tiene AbortControllers, timeouts pendientes o fetch promises, esos recursos fugan. El orden correcto es Cache → Timers → **Http** → Frontend → Dom → Formats → Storage → Lang → Logger.
- **Propuesta:** Añadir `Http.dispose()` entre `Timers.dispose()` y `teardownPersistence()`.
### HC-2: `connection.ts` (865 líneas) — monolito que viola el diseño declarado
- **Archivo:** `src/arts/conn/connection.ts`
- **Severidad:** Alta | **Clasificación:** refactor | **Esfuerzo:** Alto (4-6h)
- **Explicación:** El archivo contiene state machine, transport lifecycle, heartbeat, reconnect, auth, buffering, channels, session bridge y browser lifecycle. `DESIGN_CONN.md:440-457` declara explícitamente archivos separados (`reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`) que **no existen**. El diseño original se consolidó en un solo archivo, dificultando el mantenimiento y testing aislado.
- **Propuesta:** Extraer `reconnect.ts` (líneas 332-357, 670-710), `heartbeat.ts` (359-392), `buffer.ts` (431-456), `auth.ts` (504-541), `request.ts` (543-577). Mantener `connection.ts` como orquestador.
### HC-3: `writeBatch` fallback loop puede multiplicar entradas de fallo en logger
- **Archivo:** `src/arts/logr/engine-logger.ts:377`
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (30min)
- **Explicación:** Cuando `writeBatch` no está definido, `flushTransport` llama a `writeOne` por cada item en el buffer. Si el transport falla en cada `writeOne`, se crea una entrada sintética de fallo POR CADA ITEM. Sin `failureThrottleMs`, esto multiplica el volumen de logs catastróficamente.
- **Propuesta:** Registrar fallo a nivel de flush — si `writeOne` falla durante un flush batch, detener iteración y emitir una sola entrada de fallo para el batch.
### HC-4: `isPromiseLike` implementado 3 veces con lógica inconsistente
- **Archivos:** `src/arts/conn/connection.ts:128` vs `src/arts/sium/core/internals.ts:23` vs `src/libs/standard-schema.ts:72`
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (15min)
- **Explicación:** La versión en `connection.ts` usa `'then' in value` que retorna `true` para objetos como `{ then: 42 }` que NO son thenables, causando que `sendFrame` haga `await` de un no-promise. Las otras dos versiones usan `typeof value.then === 'function'` que es correcto. Tres implementaciones con firmas diferentes.
- **Propuesta:** Todas las implementaciones deben importar desde `$libs/standard-schema`. Eliminar las locales.
---
## Hallazgos medios
### HM-1: `Can.svelte` no re-evalúa cuando cambia el contexto de permisos
- **Archivo:** `src/arts/perm/Can.svelte:30-49`
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** El `$effect` depende de `action`, `resource`, `context`, `optimistic` — pero NO del `permissions` context. Si se llama `setPermissionsContext()` después de montar `<Can/>`, el componente no re-evalúa.
- **Propuesta:** Leer `permissions.currentSnapshot.version` dentro del effect como dependencia reactiva.
### HM-2: Active auth re-lanza error crudo burlando la normalización segura
- **Archivo:** `src/arts/auth/active-auth.svelte.ts:185-186`
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo
- **Explicación:** `catch (error) { lastError = normalizeClientError(error); throw error; }` — re-lanza el error original, que puede contener stack traces o datos internos. Si el caller captura directamente en vez de leer `Auth.lastError`, recibe el error inseguro.
- **Propuesta:** Lanzar `AuthInvalidResponseError` o `AuthRequestFailedError` con el mensaje normalizado, no el error original.
### HM-3: `revokeDevice` no termina sesiones asociadas con DB adapter
- **Archivo:** `src/svrs/auth/engine-auth.ts:513-532`
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Medio
- **Explicación:** `revokeDevice()` en el adapter de memoria sí revoca session bindings, pero el DB adapter (`db.ts:108-112`) solo actualiza el registro del dispositivo — las sesiones bindings quedan activas. Un dispositivo revocado podría mantener sesiones válidas.
- **Propuesta:** Mover la lógica de revocación de session bindings al engine (no al adapter). Llamar `sess.end()` para sesiones asociadas al dispositivo revocado.
### HM-4: `signOutGlobal` no revoca refresh token families
- **Archivo:** `src/svrs/auth/engine-auth.ts:306-334`
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo
- **Explicación:** `signOutGlobal` revoca session bindings pero NO las refresh token families del actor. Un refresh token emitido antes del logout global podría potencialmente rotar a nuevas sesiones. `refresh-rotation.ts` tiene `revokeRefreshFamily` pero no se llama.
- **Propuesta:** Añadir `store.revokeRefreshFamily()` durante `signOutGlobal`.
### HM-5: `mono-lang.svelte.ts` `register()` retorna tipo falseado
- **Archivo:** `src/arts/lang/mono-lang.svelte.ts:139-142`
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** `register()` crea `ActiveLang<LangNode>` pero lo castea `as unknown as ActiveLang<LangNode & { [K in NS]: M }>`. La instancia retornada no tiene conocimiento real del namespace — `lang.t('shop.product')` devolvería el path literal, no una traducción.
- **Propuesta:** Hacer que mono-lang's `register` realmente mergee módulos en un schema interno, o tipar el retorno como `ActiveLang<LangNode>` sin pretensión de type safety.
### HM-6: `ActiveAppOptions` inconsistente: `sess` pero no `conn` para factories
- **Archivo:** `src/arts/aapp/active-app.svelte.ts:155-261`
- **Severidad:** Media | **Clasificación:** simplificación / coherencia | **Esfuerzo:** Medio
- **Explicación:** El constructor acepta `sess` como opción con `onSignedOut()`, pero `conn` (Connections) no tiene opción equivalente para inyectar configuración inicial. Esto fuerza a llamar `App.createActiveConnections()` sin poder preconfigurar. La asimetría con `sess`/`auth`/`perm` rompe el patrón de factories.
- **Propuesta:** Aceptar `connections?: Omit<ConnectionsOptions, 'timers' | 'logger'>` en `ActiveAppOptions`.
### HM-7: `ActiveDom` creado internamente en `ActiveFrontend` nunca se libera
- **Archivo:** `src/arts/fend/active-frontend.svelte.ts:82,224-229`
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** Cuando `applyDom === true` (default) y no se pasa `dom`, se crea `ActiveDom` interno que adjunta un `resize` listener a `window`. `ActiveFrontend.dispose()` no llama a `dom.dispose()`, filtrando el listener hasta que se cierre la página.
- **Propuesta:** Guardar referencia al `dom` creado internamente y llamar `dom.dispose()` en el método `dispose()`.
### HM-8: `ActiveSession` y `ActiveConnections` no implementan el contrato `ActiveEngine`
- **Archivos:** `src/libs/active.ts`, `src/arts/sess/`, `src/arts/conn/`
- **Severidad:** Media | **Clasificación:** refactor / coherencia | **Esfuerzo:** Bajo
- **Explicación:** `ActiveEngine<TSnapshot, TError>` es implementado por `ActiveAuth`, `ActivePermissions`, `ActiveCache` pero NO por `ActiveSession` ni `ActiveConnections` — ambos tienen `loading`, `lastError`, `snapshot()`, `dispose()` y `onChange()`. El contrato está a medio adoptar.
- **Propuesta:** Extender `ActiveSession` y `ActiveConnections` con `ActiveEngine` o eliminar el contrato parcial y documentar que es solo para "network-augmented" artifacts.
### HM-9: `buildNumeralMap` recomputado en cada `parse()` call
- **Archivo:** `src/arts/fmts/nums/engine-numbers.ts:37-44,119-123`
- **Severidad:** Media | **Clasificación:** optimización | **Esfuerzo:** Bajo
- **Explicación:** Cada llamada a `parse()` invoca `buildNumeralMap(locale)` que crea `Intl.NumberFormat`, formatea un número constante y construye un `Map` iterando caracteres. Este mapa es constante por locale.
- **Propuesta:** Cachear el numeral map por locale, similar al `formatCache`.
### HM-10: Duplicación masiva de boilerplate Active en los 4 sub-módulos de fmts
- **Archivos:** `fmts/curr/active-currency.svelte.ts`, `fmts/dates/active-dates.svelte.ts`, `fmts/nums/active-numbers.svelte.ts`, `fmts/unts/active-units.svelte.ts`
- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Medio
- **Explicación:** Cuatro archivos comparten ~80% de estructura idéntica: `version = $state(0)`, `SvelteSet` para listeners, `notifyPreferences()`, `syncLocale()`, `unsubscribeLocale`, `dispose()`. ~100 líneas cada uno con ~60 líneas de boilerplate.
- **Propuesta:** Crear helper genérico `createReactiveSubEngine<E>(engine, subs)` en `fmts/helpers.ts`. Cada wrapper bajaría a ~30 líneas.
### HM-11: `unref` pattern duplicado 3 veces
- **Archivos:** `src/arts/http/retry.ts:68`, `src/arts/http/timeout.ts:52,68`
- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Explicación:** `(id as unknown as { unref?: () => void }).unref?.()` aparece 3 veces.
- **Propuesta:** Extraer a `tryUnref(handle: unknown)` en `$libs/timers`.
---
## Hallazgos menores
### HL-1: `libs/times/index.ts` — módulo vacío (dead code)
- **Archivo:** `src/libs/times/index.ts`
- **Severidad:** Baja | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Propuesta:** Poblar con utilidades de tiempo o eliminar el directorio y alias.
### HL-2: `resolveDir` duplicado entre `arts/fend/locale-defaults.ts` y `libs/dom/locale.ts`
- **Archivos:** `src/arts/fend/locale-defaults.ts:8-10`, `src/libs/dom/locale.ts:1-5`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Mover `resolveDir` y `RTL_LOCALES` a `libs/dom/locale.ts`. Re-exportar desde fend.
### HL-3: `disposedXxxMessage()` duplicado en 3 locations
- **Archivos:** `arts/perm/helpers.ts:3-5`, `svrs/perm/helpers.ts:3-5`, `svrs/cach/helpers.ts:3-5`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Extraer a `$libs/active` como `disposedMessage(artifact, method)`.
### HL-4: `isLangBranch` vive en `helpers.ts` pero pertenece a `guards.ts`
- **Archivo:** `src/arts/lang/helpers.ts:171-179`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Mover a `guards.ts`.
### HL-5: `toError` helper duplicado
- **Archivos:** `src/arts/sess/engine-session.ts:792-794`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Mover a `$libs/reactive/utils`.
### HL-6: `NodeJS.Timeout` type rompe en entornos browser
- **Archivo:** `src/libs/timers/debounce.ts:3`
- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Propuesta:** Usar `ReturnType<typeof setTimeout>`.
### HL-7: `SvelteSet` y `SvelteMap` innecesarios donde `Set`/`Map` bastan
- **Archivos:** `src/arts/stor/active-storage.svelte.ts:46,115`
- **Clasificación:** optimización | **Esfuerzo:** Bajo
- **Explicación:** `listeners` y `userSubs` solo se iteran imperativamente (`.forEach`, `.values()`), nunca en `$derived` o template. La reactividad de SvelteSet/Map no se aprovecha.
- **Propuesta:** Reemplazar con `Set` y `Map` planos.
### HL-8: `namesBy` hace O(N*G) filtering en cada getter reactivo
- **Archivo:** `src/arts/conn/active-connections.svelte.ts:36-76`
- **Clasificación:** optimización | **Esfuerzo:** Bajo
- **Propuesta:** Precomputar arrays categorizados con un solo `$derived`.
### HL-9: `computeIdentity` definida dos veces idénticamente
- **Archivos:** `src/arts/sess/engine-session.ts:188-191`, `src/arts/sess/active-session.svelte.ts:31-34`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** El wrapper active debe delegar a `engine.identity` en vez de recomputar.
### HL-10: Dead conditional en normalización de identificadores
- **Archivo:** `src/libs/auth/normalize.ts:15-17`
- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
- **Explicación:** Ambas ramas del ternario llaman `trimmed.toLocaleLowerCase()`. El condicional está muerto.
- **Propuesta:** Eliminar condicional o aplicar normalización diferente por rama.
### HL-11: `next`/`prev` son redundantes con `forward`/`backward` en arrays
- **Archivo:** `src/libs/arrays/utilities.ts:89-169`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Reimplementar `next`/`prev` como wrappers de `forward(array, index, 1, loop)`.
### HL-12: `libs/http/index.ts` y todas las barrels de `libs/*` usan `export *`
- **Archivos:** `src/libs/*/index.ts`
- **Severidad:** Baja | **Clasificación:** simplificación / coherencia
- **Explicación:** El `arts/README.md` afirma que "All barrels use named re-exports" — esto es falso para toda la capa `libs/`. Para libs de utilidades es aceptable, pero para `libs/auth` (400+ líneas de tipos), `libs/perm`, `libs/cach` penaliza el tree-shaking.
- **Propuesta:** Actualizar README para reflejar la realidad: "libs barrels usan `export *`; arts barrels usan named re-exports." Opcional: convertir las libs grandes a named re-exports.
### HL-13: `libs/numbers/utilities.ts` — parámetro confuso `numerator`
- **Archivo:** `src/libs/numbers/utilities.ts:24`
- **Clasificación:** refactor | **Esfuerzo:** Bajo
- **Propuesta:** Renombrar a `mod(value: number, modulus: number)`.
### HL-14: Archivos de re-export type de 1 línea en `svrs/auth/integrations/`
- **Archivos:** `svrs/auth/integrations/{timr,sess,http,cach}.ts` (1 línea cada uno)
- **Clasificación:** simplificación | **Esfuerzo:** Bajo
- **Propuesta:** Eliminar archivos intermedios. Re-exportar directamente desde `svrs/auth/index.ts`.
---
## Refactorizaciones recomendadas
| # | Descripción | Archivo(s) | Esfuerzo |
|---|-------------|-----------|----------|
| R1 | Partir `connection.ts` (865 líneas) en módulos separados | `src/arts/conn/connection.ts` | Alto |
| R2 | Partir `engine-auth.ts` (951 líneas) extrayendo password flow, session binding, OAuth | `src/svrs/auth/engine-auth.ts` | Alto |
| R3 | Extraer boilerplate Active de fmts en `createReactiveSubEngine()` | `src/arts/fmts/*/active-*.svelte.ts` | Medio |
| R4 | Extraer `getLocale`/`setLocale` duplicado en 4 engines fmts | `src/arts/fmts/*/engine-*.ts` | Medio |
| R5 | Unificar `resolveDir` + `Direction` en `libs/dom/locale.ts` | fend/locale-defaults.ts, libs/dom/locale.ts | Bajo |
| R6 | Extraer `disposedXxxMessage()` a helper compartido | perm/helpers.ts, cach/helpers.ts | Bajo |
| R7 | Mover `readField`, `toError`, `escapeId` a `libs/` | sess/engine-session.ts, adom/roving-focus-group | Bajo |
| R8 | Convertir `libs/auth/index.ts` de `export *` a named re-exports | `src/libs/auth/index.ts` | Medio |
| R9 | Actualizar `DESIGN_CONN.md` para reflejar la implementación real | `src/arts/conn/DESIGN_CONN.md` | Medio |
| R10 | Alinear `activeEngine` contract: extender `ActiveSession`/`ActiveConnections` | `src/libs/active.ts` | Bajo |
---
## Simplificaciones recomendadas
| # | Descripción | Archivo | Esfuerzo |
|---|-------------|---------|----------|
| S1 | Eliminar `close()` duplicado de `engine-connections` (alias de `closeConnection`) | `src/arts/conn/engine-connections.ts:163-165` | Bajo |
| S2 | Eliminar `FormatsLocaleSource` (type alias muerto de `LocaleSource`) | `src/arts/fmts/types.ts:10` | Bajo |
| S3 | Consolidar `AUTO_VALUE`/`AUTO_CURRENCY`/`AUTO_UNIT_SYSTEM` (todos son `'auto'`) | `fmts/consts.ts`, `curr/consts.ts`, `unts/consts.ts` | Bajo |
| S4 | Reemplazar `SvelteSet`/`SvelteMap` innecesarios con `Set`/`Map` | `active-storage.svelte.ts:46,115` | Bajo |
| S5 | Eliminar archivos de 1 línea en `svrs/auth/integrations/*` | `svrs/auth/integrations/` | Bajo |
| S6 | Eliminar `svrs/auth/context.ts` (usado solo en test page) | `svrs/auth/context.ts` | Bajo |
| S7 | `noop()` debería aceptar rest args para compatibilidad universal | `libs/funcs/noop.ts:4` | Bajo |
| S8 | Simplificar `subscribe()` delegando a `addTransport` en logger | `src/arts/logr/engine-logger.ts:543-551` | Bajo |
---
## Optimizaciones recomendadas
| # | Descripción | Archivo | Esfuerzo |
|---|-------------|---------|----------|
| O1 | Cachear `buildNumeralMap` por locale (recomputado en cada `parse()`) | `fmts/nums/engine-numbers.ts:119-123` | Bajo |
| O2 | Precomputar `namesBy` en un solo `$derived` en vez de 5 filtros O(N) | `conn/active-connections.svelte.ts:36-76` | Bajo |
| O3 | `getLogs` clona todas las entradas antes de filtrar — filtrar primero | `logr/engine-logger.ts:473-490` | Bajo |
| O4 | `Object.keys(globalContext).length > 0` aloca array en hot path | `logr/engine-logger.ts:194-199` | Bajo |
| O5 | `sameSnapshot` usa `JSON.stringify` en cada cambio externo — shortcut con `generation` | `sess/engine-session.ts:786-789` | Bajo |
| O6 | `refines.ts` `regex()` clona RegExp innecesariamente sin flags `g`/`y` | `sium/core/refines.ts:229` | Bajo |
| O7 | `cookieAdapter.get()` re-parsea `document.cookie` en cada lectura | `stor/adapters/cookie.ts:94-96` | Medio |
| O8 | Timers sin cancelar en `body-scroll-lock` durante HMR reloads | `adom/body-scroll-lock.svelte.ts:150-169` | Bajo |
| O9 | `$effect` sin debounce en `Can.svelte` para cambios rápidos de props | `perm/Can.svelte:30-48` | Bajo |
| O10 | `decisionKey()` llamada incluso para cache hits — diferir tras cache miss | `perm/client.ts:252-254` | Bajo |
---
## Incoherencias de arquitectura
1. **Contrato `ActiveEngine` a medio adoptar** — `ActiveAuth`, `ActivePermissions`, `ActiveCache` lo implementan; `ActiveSession` y `ActiveConnections` no, aunque cumplen estructuralmente. O se adopta universalmente o se elimina.
2. **Clases vs factories en `adom`** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` son clases con `new`; el resto del ecosistema usa `createEngine*`/`createActive*`. Inconsistencia de API.
3. **Naming plural vs singular** — `createEngineTimers` (plural) vs `createEngineHttp` (singular). Solo `timr` usa plural.
4. **Errores: clases vs string-templates** — `timr`/`http`/`conn`/`perm` usan clases Error con type guards; `fmts` usa string factories. Inconsistente para `catch` programático.
5. **Patrón de errores `disposed`** — `CachDisposedError`, `PermDisposedError`, `AuthDisposedError` vs `STORAGE_ERRORS.DISPOSED` (string). Sin patrón unificado.
6. **`DESIGN_CONN.md` referencia archivos inexistentes** — `reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`, `presence.ts`, `app-integration.ts` no existen. El diseño se consolidó sin actualizar la documentación.
7. **`libs/times/` — alias en config pero directorio vacío** — el alias `$libs/times` resuelve a un `index.ts` de 0 bytes.
8. **`AappAlreadyCreatedError` no se usa para sesión** — `createActiveSession()` lanza `SessAlreadyCreatedError`, no `AappAlreadyCreatedError` como los demás factories.
9. **Sin `DESIGN_*.md` para http, fmts, stor** — solo `timr` y `conn` tienen documentos de diseño detallados.
---
## Tests faltantes
### Sin tests (crítico)
| Módulo | Archivos sin tests |
|--------|-------------------|
| `libs/timers` | `backoff.ts`, `debounce.ts` (0 tests) |
| `arts/conn` | `active-connections.svelte.ts` (sin archivo de test) |
| `arts/conn` | `websocket.ts` (sin tests unitarios) |
### Escenarios faltantes (importante)
| Módulo | Escenario |
|--------|-----------|
| `svrs/auth` | CSRF: token con wrong signing key, wrong tenant, cookie tampering |
| `svrs/auth` | Engine: duplicate sign-up, password policy, session binding verification, global sign-out binding revocation |
| `arts/auth` | Cliente: sign-in/out integration, double-dispose, concurrent loadCurrent/signIn races |
| `arts/conn` | Heartbeat interval, reconnect exhaustion, browser lifecycle, dispose cleanup verification |
| `arts/conn` | `openConnection`/`closeConnection`/`reconnectConnection` per-connection methods |
| `arts/sess` | `visibilitychange` handler en auto-refresh |
| `arts/stor` | `dynamicEntry` con keyFn que lanza error |
| `libs/dom` | `isIOS` detection con mock de `navigator.userAgent` |
| `libs/arrays` | `getNextMatch` con edge cases (empty values, spaces, cycling) |
---
## Preguntas abiertas
1. **¿Debe `ActiveEngine` ser contrato universal o solo para artifacts con side-effects?** — Actualmente a medio adoptar. O se extiende a Session/Connections o se documenta como específico de "network-augmented" artifacts.
2. **¿Mantener clases en `adom` o migrar a factories?** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` usan `new`; el resto usa `create*()`. La inconsistencia actual confunde.
3. **¿Cuál es el plan para `libs/times/`?** — Directorio vacío con alias en config. ¿Se puebla con duration math, `delay()`, `sleep()` o se elimina?
4. **¿Nivel de madurez de OAuth y MFA?** — El README dice "no deben documentarse como production-ready". `verifyMfaChallenge` siempre lanza error. OAuth tiene incompatibilidad con DB adapter. ¿Roadmap?
5. **¿Estándar de idioma para documentación?** — `adom/README.md` está en español, `stor/README.md` en inglés. Sin estándar definido.
6. **¿Mover validación de sesión en SSR al engine?** — `readSessionFromCookies` no valida schema; depende del caller pasar por `adoptServer`. ¿Debería el helper ser más defensivo?
7. **¿Estrategia de barrels?** — El README dice "named re-exports" para todos los barrels pero `libs/*` usa `export *`. ¿Actualizar README o convertir libs?
---
## Veredicto
**El ecosistema Active es un framework sólido, bien diseñado y con fundamentos arquitectónicos excelentes.** La separación en capas, el patrón Engine/Active, la política de tree-shaking, el aislamiento de scope en caché y permisos, y la implementación de CSRF son de calidad profesional.
**Lo que frena la calidad hoy:**
1. **Deuda de consolidación** — Archivos monolíticos (`connection.ts` 865 líneas, `engine-auth.ts` 951 líneas) que contradicen su propio diseño documentado. La duplicación de boilerplate entre sub-módulos de fmts y entre capas cliente/servidor indica que el framework creció sin pausas de refactorización.
2. **Cobertura de tests desigual** — Algunos módulos tienen baterías exhaustivas (50 tests en `engine-timers.test.ts`, 622 líneas en `engine-http.test.ts`); otros tienen cero tests (`libs/timers`, `active-connections`, `websocket`). Las áreas sin tests son precisamente donde hay más bugs potenciales (conexiones, reconexión, heartbeats).
3. **Convenciones inconsistentes** — Nombres plural/singular, clases vs factories, errores clase vs string, contrato `ActiveEngine` a medio adoptar. Esto crea fricción para nuevos contribuidores y hace que el código parezca menos cohesionado de lo que realmente es.
**Orden de actuación recomendado:**
1. **Semana 1-2 — Corrección de bugs:** HC-1 (Http dispose), HC-3 (writeBatch loop), HC-4 (isPromiseLike), HM-1 (Can reactivity), HM-2 (auth error re-throw)
2. **Semana 3-4 — Refactors estructurales:** Partir `connection.ts`, extraer boilerplate fmts, añadir Http.dispose()
3. **Semana 5-6 — Seguridad:** HM-3 (revokeDevice DB), HM-4 (signOutGlobal refresh families), HM-6 (session fixation docs)
4. **Semana 7-8 — Tests:** Añadir tests para `libs/timers`, `active-connections`, heartbeat, CSRF edge cases
5. **Mes 2-3 — Unificación:** Adoptar `ActiveEngine` universalmente o eliminarlo, unificar naming (plural→singular en timr), estandarizar errores (clases everywhere), actualizar DESIGN_CONN.md
**Lo que está excepcionalmente bien:**
- Separación cliente/servidor: cero imports cruzados `$arts`↔`$svrs` (fuera de tests de integración)
- CSRF: double-submit cookie + HMAC-SHA256 + timing-safe comparison + `__Host-` prefix
- Scope isolation en caché: `SCOPE_ACTOR`, `SCOPE_TENANT`, `SCOPE_PERMISSION` con hash en key
- Generation guard en permisos: previene escritura de resultados stale en snapshots posteriores
- Sistema de timers deterministas con fake clock injection para testing
- Tree-shaking: separación `.svelte.ts` vs `.ts`, barrels con named re-exports en `arts/`
- Documentación de diseño: `DESIGN_TIMR.md` (1530 líneas) y `DESIGN_CONN.md` (1770 líneas) son ejemplares

@ -0,0 +1,709 @@
# AUDIT_claude
> Auditoría profunda del ecosistema Active en `src/`. Sin cambios de código.
> Cada hallazgo verificado lleva archivo y línea aproximada. Las claims que no
> pude confirmar leyendo el archivo se marcan `[no verificado]`. Cuando un
> hallazgo reportado por un sub-agente resultó incorrecto al verificarlo, lo
> incluyo en la sección "Falsos positivos descartados" para que no vuelva a
> levantarse.
## Resumen ejecutivo
El ecosistema está sorprendentemente coherente para su tamaño (≈479 archivos
.ts/.svelte). Las convenciones (`createEngineXxx` / `createActiveXxx`,
`ActiveEngine<TSnapshot, TError>`, dispose idempotente, constantes
centralizadas, named exports) se aplican con consistencia notable; lang y
logr son tan limpios que sirven de plantilla para el resto. Los tests de
`sium`, `stor`, `sess`, `lang` y `logr` son sólidos.
Los problemas serios se concentran en tres puntos:
1. **Composición de seguridad incompleta en `aapp`.** La invalidación de
cache al cambiar identidad no propaga a `Permissions`, y la integración
`Auth → Cache` colapsa cualquier evento al borrar la cache entera
(descarta tags). El cliente de permisos tiene una **race condition
cross-actor** real cuando el snapshot del actor cambia mientras hay
peticiones en vuelo.
2. **Ramas server-authoritative parcialmente implementadas.** `svrs/auth`
define `AuthRateLimitPort` pero no lo cablea en ningún flujo.
`verifyMfaChallenge` lanza `AuthConfigError` (stub). El intercambio OAuth
PKCE no pasa el `verifier` al provider. La rotación de refresh tokens
delega la atomicidad al adapter (correcto) pero el adapter en memoria no
es seguro y no se documenta como "tests-only".
3. **Cobertura de tests muy desigual.** `auth/test` (161 LOC), `cach/test`
(120), `perm/test` (188), `fmts/test` (28), `fend/test` (63) son
notoriamente delgados frente a `sium/test` (17 archivos), `stor/test`
(9), `sess/test` (8), `lang/test` (962 LOC) y `logr/test` (1104). Las
áreas más críticas para producción están menos cubiertas.
Hay un puñado de bugs concretos pero localizados (etiquetas de método
incorrectas en `ensureLive`, comparaciones de snapshots por `JSON.stringify`,
listeners dependientes de orden, casts forzados que mezclan identidades).
Ninguno tira el framework, pero ya levanta deuda visible.
Estado general: **sólido en esqueleto, frágil en seguridad/ops**. Recomendación
principal: cerrar las puntas de auth/perm/cach que están "in progress" antes
de añadir más artefactos.
---
## Hallazgos críticos
### C1. `[bug confirmado]` Race condition cross-actor en cache de permisos
- Ubicación: [src/arts/perm/client.ts:166-181, 207-221, 238-282](src/arts/perm/client.ts#L166-L282)
- Severidad: **alta** · Esfuerzo: medio
- Evidencia:
- `decisionKey(input)` usa `resolveScopeKey()` que lee
`currentSnapshot.actor` del snapshot vigente al *momento* de calcular la
clave.
- `check()` calcula la clave al inicio (línea 240) y la usa para `pending.set(key, …)`.
- Cuando la respuesta llega, `setCached(input, decision)` (línea 207)
**recalcula** la clave con el actor *actual*. Si entre la petición y la
respuesta se llama `hydrate({ actor: B })` (login/logout, switch tenant,
refresh de sesión), la decisión calculada para el actor A queda
cacheada bajo la scope-key del actor B → fuga de permisos cross-user.
- Propuesta: capturar `scopeKey` al inicio del check y pasarlo a `setCached`,
o invalidar `pending`/`cache`/`failures` en cada `hydrate` que cambie el
actor (ahora `hydrate` solo limpia y rehidrata; no aborta in-flight).
### C2. `[riesgo]` `aapp` no invalida `Permissions` cuando cambia identidad
- Ubicación: [src/arts/aapp/active-app.svelte.ts:237-251](src/arts/aapp/active-app.svelte.ts#L237-L251)
- Severidad: **alta** · Esfuerzo: bajo
- Evidencia: en `createActiveAuth` se inyecta
`cach: { invalidate: () => Cache.clear() }` pero no se pasa nada al
`Permissions` activo. Tampoco hay un wiring `Auth → Permissions.invalidate()`
o `Sess → Permissions.invalidate()`. Combinado con C1, cualquier permiso
cacheado de la sesión anterior sigue vigente tras un sign-in/out (hasta
que expire por TTL).
- Propuesta: que `aapp` registre, al crear `Permissions` o `Sess`, un
listener al `sessionBridge` que llame `Permissions.invalidate()` con el
scope previo. O mejor, exponer un hook `cach`-style en
`ActivePermissionsOptions` y conectarlo en `aapp`.
### C3. `[riesgo]` `Auth → Cache.clear()` descarta tags y limpia todo
- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247) + [src/arts/auth/active-auth.svelte.ts:281](src/arts/auth/active-auth.svelte.ts#L281)
- Severidad: media-alta · Esfuerzo: bajo
- Evidencia: el helper `authCacheTagsForIdentity()` produce tags (`auth.current`,
`auth.devices`, `auth.factors`) y `ActiveAuth` los pasa, pero `aapp`
ignora los args y llama `Cache.clear()` total. Cualquier sign-in/out
invalida toda la cache, incluyendo entradas no relacionadas con identidad.
Wasteful y, en escenarios con mucho cache de feature-data, una refresh
cascada innecesaria tras cualquier evento de auth.
- Propuesta: implementar `cach.invalidate({ tags, reason })` real en `aapp`
(`Cache.invalidate({ tags })`).
### C4. `[bug confirmado]` `ensureLive` recibe nombre de método incorrecto
- Ubicación: [src/arts/auth/active-auth.svelte.ts:233](src/arts/auth/active-auth.svelte.ts#L233)
- Severidad: media · Esfuerzo: trivial
- Evidencia: `onChange(listener)` llama `ensureLive(AUTH_METHOD_LOAD_CURRENT)`.
Si el active está disposed, el `AuthDisposedError` reportará el método
equivocado. Caso parecido en `active-permissions.svelte.ts:111` donde
`clearError` y `decisionKey` reusan `PERMISSION_METHOD_CHECK`.
- Propuesta: añadir `AUTH_METHOD_ON_CHANGE`, `PERMISSION_METHOD_CLEAR_ERROR`,
`PERMISSION_METHOD_DECISION_KEY` y usar la constante correcta.
### C5. `[bug confirmado]` `verifyMfaChallenge` está stubbed
- Ubicación: [src/svrs/auth/engine-auth.ts:641-643](src/svrs/auth/engine-auth.ts#L641-L643)
- Severidad: alta para usar en producción · Esfuerzo: alto
- Evidencia: `async function verifyMfaChallenge(_input) { throw new AuthConfigError(...) }`.
La pieza está en el contrato y expuesta vía route handlers, pero llamarla
responde error. No hay banner en el README de `svrs/auth` que avise.
- Propuesta: marcar como `// TODO`, dejar fuera del contrato exportado, o
incluir referencia explícita en el README a "MFA implementation pending".
### C6. `[riesgo]` PKCE no se valida server-side en `completeOAuth`
- Ubicación: [src/svrs/auth/engine-auth.ts:579-617](src/svrs/auth/engine-auth.ts#L579-L617)
- Severidad: alta · Esfuerzo: medio
- Evidencia: `startOAuth` genera `verifier` y guarda
`metadata: { state, verifier }` en el flow, pero `completeOAuth` solo
recupera el flow por `stateHash`, llama
`provider.mapProfile({ tokens: { code } })` y consume el flow. **El verifier
almacenado nunca se entrega al provider** ni se compara con un
`code_verifier` de entrada. La construcción del PKCE pair (`oauth/pkce.ts`)
es correcta (BASE64URL(SHA256(verifier))) pero no se cierra el ciclo.
- Propuesta: pasar `flowCandidates.metadata?.verifier` a
`provider.mapProfile`, y exigir que el provider lo use en el token
exchange. Validar que el `code_verifier` derivado coincide con el
`code_challenge` enviado.
### C7. `[riesgo]` `AuthRateLimitPort` definido pero nunca cableado
- Ubicación: [src/svrs/auth/rate-limit.ts](src/svrs/auth/rate-limit.ts) + [src/svrs/auth/engine-auth.ts](src/svrs/auth/engine-auth.ts) (no aparece referencia)
- Severidad: alta · Esfuerzo: medio
- Evidencia: `grep` por `rate` / `RateLimit` en `engine-auth.ts` y
`handlers.ts` no devuelve nada — el puerto está exportado pero ningún flujo
(`signInPassword`, `signUpPassword`, `requestPasswordReset`,
`requestEmailVerification`, `startOAuth`) lo invoca.
- Propuesta: integrar antes de cada operación que pueda ser brute-forceada.
Hasta que se cablee, considerar quitarlo de `index.ts` para no dar
falsa sensación de protección.
### C8. `[riesgo]` Memory adapter no es transaccional pero soporta endpoints sensibles
- Ubicación: [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157), [src/svrs/auth/refresh-rotation.ts:21-55](src/svrs/auth/refresh-rotation.ts#L21-L55)
- Severidad: media · Esfuerzo: bajo (docs)
- Evidencia: `findRefreshTokenForUpdate` y `rotateRefreshToken` están
diseñados para correr dentro de una transacción ("ForUpdate" sugiere row
lock). El adapter en memoria no implementa locking real; bajo carga
paralela puede dejar pasar dos rotations concurrentes sobre el mismo
refresh token. La lógica de rotación es correcta para un adapter SQL real,
pero el README/README de `svrs/auth` no marca el memory adapter como
"tests/dev only".
- Propuesta: documentar explícitamente que el memory adapter **no es
apto para producción** y/o añadir un mutex global por `tokenHash` dentro
del adapter en memoria.
---
## Hallazgos medios
### M1. `[bug confirmado]` `sameSnapshot` por `JSON.stringify` para session
- Ubicación: [src/arts/sess/engine-session.ts:786-790](src/arts/sess/engine-session.ts#L786-L790)
- Severidad: media · Esfuerzo: bajo
- Riesgo: si la session contiene fields cuyo orden de keys no es estable
entre origen-tab y target-tab (raro pero posible con structures cíclicas
o `JSON.stringify` polyfills), se reportarán cambios falsos. Más probable:
el coste de stringify dos sesiones en cada storage event escala con el
payload de `data`. Para apps que guardan poco, está bien; documentar el
coste y que `data` debe ser pequeño.
- Propuesta: dado que `freezeSession` ya normaliza keys, el riesgo de
desorden es bajo. Bastaría una nota en el README sobre el coste.
### M2. `[bug confirmado]` SameSite default `lax` para cookie CSRF
- Ubicación: [src/libs/auth/consts.ts:283-290](src/libs/auth/consts.ts#L283-L290)
- Severidad: media · Esfuerzo: trivial
- Evidencia: `AUTH_COOKIE_POLICY.SAME_SITE = 'lax'`. Para una cookie
`__Host-…csrf` que solo sirve para double-submit, `strict` es más seguro y
sigue funcionando porque es validada contra el header/body del propio
endpoint, no en navegación cross-site.
- Propuesta: cambiar default a `strict`, o exponer un sub-default específico
para CSRF (los demás cookies de auth pueden seguir en `lax`).
### M3. `[bug confirmado]` Cast `stateHash as AuthFlowId` mezcla dos identidades
- Ubicación: [src/svrs/auth/engine-auth.ts:717-729](src/svrs/auth/engine-auth.ts#L717-L729) + [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `findOAuthFlowByState` pasa el `stateHash` como `flowId` y el
adapter lo usa primero como id directo y, si falla, como búsqueda por
`flow.stateHash`. Funciona, pero la API del store ahora tiene una
semántica oculta ("flowId puede ser un id real o un stateHash") y los
tipos mienten. Difícil de descubrir sin leer el adapter.
- Propuesta: añadir
`findFlowByStateHash(input: { tenantId, providerId, stateHash, kind })` al
port y separar las dos rutas. Mantiene tipos honestos.
### M4. `[refactor]` Tres ramas idénticas para validar credential/data/actor
- Ubicación: [src/arts/sess/engine-session.ts:378-419](src/arts/sess/engine-session.ts#L378-L419) y [493-543](src/arts/sess/engine-session.ts#L493-L543)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `adopt` y la rama validada de `refresh` repiten el mismo patrón
4 veces ("si schema definido O field presente, validar; mapear error con
field name"). 80 LOC duplicadas.
- Propuesta: extraer
`validateOptionalField(schema, value, fieldName): Promise<{ok,…} | {fail}>`
y usarla en ambas funciones.
### M5. `[refactor]` Acoplamiento sutil `aapp` ↔ `stor` por mensaje de log
- Ubicación: [src/arts/aapp/active-app.svelte.ts:33,77-83](src/arts/aapp/active-app.svelte.ts#L33-L83)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `aapp` importa
`LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY` y `APP_STORAGE_ERROR_MESSAGE`
para reportar errores del adapter. La política de "qué mensaje y qué
categoría usar" está dividida entre dos módulos.
- Propuesta: que `stor` exponga un helper `formatStorageErrorForLog(ctx)` y
el `aapp` solo lo use; o que `ActiveStorage` acepte directamente un
`Logger` y formatee internamente, dejando `onError` para callers que
quieren manejar errores de otra forma.
### M6. `[refactor]` `Cache.clear()` ignora tags y vuelve `cach.invalidate` un alias mentiroso
- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247)
- Severidad: media · Esfuerzo: bajo
- Cubierto en C3. Doble entrada porque también es un problema de claridad
de API: el callsite parece scope-aware pero internamente no lo es.
### M7. `[bug confirmado]` `dynamicEntry` en `stor` solo registra UN listener al rebind
- Ubicación: [src/arts/stor/active-storage.svelte.ts:115-127](src/arts/stor/active-storage.svelte.ts#L115-L127) (verificar líneas exactas en su versión actual)
- Severidad: media · Esfuerzo: medio
- Evidencia (parcial, no leí el archivo entero): el patrón de `userSubs:
Map<fn, detacher>` reasigna el detacher en cada rebind, lo que suelta
el listener anterior y registra uno nuevo. Es correcto siempre que la
función `fn` sea estable. Si el caller usa una arrow inline, cada rebind
agrega una entrada nueva sin liberar la anterior. Documentar que `fn`
debe ser estable.
- Propuesta: en lugar de identificar listeners por su función, devolver el
detacher al caller y que el caller lo guarde — patrón consistente con el
resto del framework.
### M8. `[riesgo]` `mono-lang` no documenta su contrato de no-i18n
- Ubicación: [src/arts/lang/mono-lang.svelte.ts](src/arts/lang/mono-lang.svelte.ts)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `aapp` cae a `createActiveMonoLang` cuando no se pasa `lang`,
con un cast `as unknown as ActiveLang<S>`. Si un caller depende de tipos
estrictos del schema, ese cast borra la garantía. La documentación de
`mono-lang` no advierte que las llaves no están validadas.
- Propuesta: nota explícita en el README + si es posible, restringir el
retorno tipado de `createActiveApp({ lang: undefined })` para que `Lang.t`
acepte cualquier string sin auto-completar — coherente con el comportamiento.
### M9. `[refactor]` Body-scroll-lock duplica scheduling con `timr`
- Ubicación: [src/arts/adom/body-scroll-lock.svelte.ts](src/arts/adom/body-scroll-lock.svelte.ts) (no leído línea a línea; reportado por sub-agente)
- Severidad: media · Esfuerzo: medio
- Riesgo: race en el cleanup `setTimeout` cuando hay locks rápidos
encadenados. Si se confirma con un test (no existe), aprovechar para
delegar a `EngineTimers` (`timr`) y eliminar el setTimeout local.
- Propuesta: usar `App.Timers.schedule()`. Beneficio extra: deterministic
para tests con `clock` inyectado.
### M10. `[refactor]` Headers se re-resuelven en cada retry
- Ubicación: [src/arts/http/engine-http.ts] (línea ~271 según sub-agente)
- Severidad: media · Esfuerzo: bajo
- Evidencia indirecta: si `mergeHeaders(defaults.headers, init?.headers)`
invoca a un `headers` hook costoso (p.ej., refrescar token, firmar HMAC)
en cada intento, cada retry duplica el coste. Para refresh tokens bajo
presión esto puede colgar requests.
- Propuesta: cachear el resultado del primer cómputo de headers y solo
recomputar si el `beforeRetry` lo solicita explícitamente.
### M11. `[bug confirmado]` `eventCount` y `loadingCount` con `untrack` en `cach`
- Ubicación: [src/arts/cach/active-cache.svelte.ts:55-64](src/arts/cach/active-cache.svelte.ts#L55-L64)
- Severidad: baja-media · Esfuerzo: trivial
- Evidencia: `eventCountCell = untrack(() => eventCountCell) + 1`. Como el
callback `engine.on(CACHE_EVENT_ALL, …)` se invoca desde el motor (no
dentro de un `$derived`/`$effect`), el `untrack` es defensivo pero ruidoso
e induce a los lectores a creer que hay un ciclo reactivo escondido.
- Propuesta: si los tests pasan sin `untrack`, quitarlo. Si hay un caso que
requiere `untrack`, comentar el porqué.
### M12. `[riesgo]` `Cache.clear()` no aborta promises en vuelo
- Ubicación: [src/arts/cach/active-cache.svelte.ts:167-170](src/arts/cach/active-cache.svelte.ts#L167-L170) + engine
- Severidad: media · Esfuerzo: medio
- Evidencia: `clear()` se delega a `engine.clear()`. Si una `query()`
estaba en vuelo, su `setCached` posterior puede repoblar la cache que
acaba de ser borrada. Mismo problema que C1, en otro escenario.
- Propuesta: incrementar un `clearGeneration` y descartar resultados de
fetches iniciados antes de la última `clear()`.
### M13. `[refactor]` `signOut` cliente es optimista pero estado se reescribe sólo si la red OK
- Ubicación: [src/arts/auth/active-auth.svelte.ts:102-110](src/arts/auth/active-auth.svelte.ts#L102-L110)
- Severidad: media · Esfuerzo: bajo
- Evidencia: la asignación `current = createAnonymousAuthCurrent()` ocurre
*después* del `await options.http.post(SIGN_OUT)`. Si la red falla, el
usuario sigue "authenticated" en la UI aunque la cookie del servidor se
haya eliminado. En cookie-auth puro, una respuesta 5xx puede dejar al
cliente desincronizado.
- Propuesta: dos opciones: (a) limpiar localmente *antes* del POST y
rollback si el server responde 401 confirmando que ya no había sesión;
(b) en el catch, si el error es de red, igual limpiar localmente y dejar
que la próxima `loadCurrent` resuelva el estado real.
### M14. `[riesgo]` `BroadcastChannel` no parsea `event` ni `generation`
- Ubicación: [src/arts/sess/engine-session.ts:154-180](src/arts/sess/engine-session.ts#L154-L180)
- Severidad: baja-media · Esfuerzo: bajo
- Evidencia: el listener trata `data?.type !== BROADCAST_TYPE` como guard
de seguridad, lo cual cubre payloads ajenos. Pero si el remitente de la
misma BC envía un `type` correcto pero un `event`/`generation` corrupto,
el código lee `storage` directamente — está bien — pero igual entrega un
`EXTERNAL_CHANGED` con el snapshot persistido, que puede no concordar con
el `event` del mensaje. No produce comportamiento incorrecto pero hace
que `event` y `current` no estén ligados al mensaje recibido.
- Propuesta: como ya se delega en `storage`, ignorar el `event` del
broadcast y simplemente disparar un re-read; el modelo actual hace eso, así
que solo bastaría documentar.
### M15. `[refactor]` Permisos: `pending` debería re-cuparse al cambiar actor
- Ubicación: [src/arts/perm/client.ts:140-147 + 360-388](src/arts/perm/client.ts#L140-L388)
- Severidad: media · Esfuerzo: bajo
- Evidencia: `hydrate(snapshot)` y `invalidate(scope)` no tocan `pending`.
Si invalidate corre durante in-flight, los caches `pending` tras la
resolución repoblarán datos que ya no debieran existir.
- Propuesta: `pending.clear()` dentro de `hydrate` e `invalidate(undefined)`,
y filtrar por scope en `invalidate(scope)`.
### M16. `[bug confirmado]` `aapp` permite varios `connectionRegistries` pero sin aviso
- Ubicación: [src/arts/aapp/active-app.svelte.ts:200-212](src/arts/aapp/active-app.svelte.ts#L200-L212)
- Severidad: baja-media · Esfuerzo: trivial
- Evidencia: `Sess`, `Permissions` y `Auth` levantan `AlreadyCreated*Error`
si se piden dos veces, pero `createActiveConnections` no. Los tests
`aapp/test` parecen aceptarlo. Inconsistencia con el patrón.
- Propuesta: o documentar explícitamente que `Connections` es multi-instancia
(channels separados) o aplicar la misma regla.
### M17. `[refactor]` `lang` `void _schemaVersion` como hack reactivo
- Ubicación: `src/arts/lang/active-lang.svelte.ts` (línea ~63 según
sub-agente) — patrón frágil para forzar lectura reactiva.
- Severidad: media · Esfuerzo: bajo
- Propuesta: documentar el porqué con un bloque comentado, o usar
`$derived.by(() => { schemaVersion; return … })` para que el dev tooling
lo vea explícitamente.
### M18. `[riesgo]` `dispose()` orden en `aapp` no detiene timers in-flight
- Ubicación: [src/arts/aapp/active-app.svelte.ts:253-276](src/arts/aapp/active-app.svelte.ts#L253-L276)
- Severidad: media · Esfuerzo: bajo
- Evidencia: el orden parece intencional pero no se documenta. `Cache.dispose()`
se llama antes que `Timers.dispose()`. Si la cache tiene un timer
programado en `Timers`, ese timer queda suelto hasta que se dispose
`Timers`. Como `Timers.dispose()` cancela todos, el efecto neto es
correcto en este orden, pero invertir destruiría la cache primero y
podría disparar un last-tick. Mantener el orden y documentarlo.
- Propuesta: comment de cabecera con la regla `consumers → providers`.
---
## Hallazgos menores
### m1. `[docs]` Inconsistencias entre `arts/README.md` y READMEs por artefacto
- `arts/README.md:51` dice de `logr`: "Structured logger: levels, transports,
filters, vitals, dispose". `logr/README.md` debe explicitar igual y
alinear el lenguaje (algunos READMEs llaman a `transport` "adapter").
### m2. `[docs]` `fmts/README.md` no aclara que `createRates(...)` es demo
- `fmts` documenta currency conversion pero no explicita que el rate provider
es responsabilidad del consumidor.
### m3. `[docs]` `cach/README.md` no documenta qué pasa si el `fetcher` lanza
- ¿Se marca la entrada como error? ¿Se conserva `data` previa con `status:
ERROR`? El código (active-cache.svelte.ts:254-258) lo hace, pero no está
en docs.
### m4. `[refactor]` Magic strings de marca "asoma" en cookies
- `src/libs/auth/consts.ts:54-57, 77-78` hardcodea "asoma". Para un
framework reutilizable, conviene `BRAND_NAME` configurable y derivar
cookie names.
### m5. `[simplificación]` `mapSendToJoinResult` en `conn/channel.ts:42-52`
- Mapeo trivial; inline o usar `as const` table.
### m6. `[refactor]` `helpers.ts` y `consts.ts` con cientos de identifiers en algunos artefactos
- `auth/consts.ts` y `sess/consts.ts` exportan ≈80 constantes cada uno.
Considerar agrupar en namespaces (`AUTH_METHODS`, `AUTH_HEADERS`, ya hecho
parcialmente) y reducir el surface por named import.
### m7. `[docs]` `arts/README.md` Map menciona `EngineSium` pero no `ActiveSium`
- Verificar que `sium` realmente no expone una versión Active. Si así es,
documentar que `sium` es un caso especial (engine-only); ya está
contemplado pero la fila no lo deja claro.
### m8. `[simplificación]` `TimerKey` interno en `conn` duplica conceptos de `timr`
- `connection.ts:316-330` (según sub-agente) maneja
`scheduleTimer`/`scheduleInterval` con keys propias. Ya tiene `timr` con
`(id, key, version)`. Posible delegación.
### m9. `[docs]` Dispose contract no está formalizado en cada README
- `arts/README.md` dice "dispose() es idempotente". Algunos READMEs (sess,
cach, auth) repiten la garantía; otros no. Estandarizar línea boilerplate.
### m10. `[refactor]` `aapp/integrations/frontend-storage` exporta nombre
largo + tres helpers que se usan solo desde `active-app.svelte.ts`
- Considerar inline o convertir en method privado del `ActiveApp`.
### m11. `[simplificación]` `Logger.dispose()` cierra y vacía pero no expone snapshot
- A diferencia de otros, `EngineLogger` no tiene `snapshot()`/`onChange`. OK
porque no implementa `ActiveEngine`. Documentar que es intencional.
### m12. `[docs]` `arts/conn/DESIGN_CONN.md` y `arts/timr/DESIGN_TIMR.md` y
`arts/sess/DESIGN.md` viven solo en sus carpetas
- Considerar enlazarlos desde `arts/README.md` para visibilidad. Los
decisivos no se ven a menos que el lector navegue.
### m13. `[test]` `aapp/test` (5 archivos) cubre composición pero no orden de
dispose
- Añadir test que verifique que disposal corre `consumers → providers`.
### m14. `[bug confirmado]` `Sentry DSN` queda en `sessionStorage` del test page
- `web/routes/test/logr/+page.svelte` guarda DSN en sessionStorage; al
navegar entre tests, persiste. Privacidad/uso accidental en producción.
### m15. `[docs]` SSR contract per-artefacto
- `timr`, `conn`, `adom`, `fend` no documentan explícitamente SSR. Una
sección "SSR considerations" por artefacto evitaría sorpresas.
---
## Refactorizaciones recomendadas
1. **Centralizar invalidación cross-artefacto**. Un `IdentityChannel`
(probablemente extensión de `sessionBridge`) al que `Cache` y
`Permissions` se suscriban. Hoy `aapp` suelta listeners ad hoc y mezcla
responsabilidades.
2. **Extraer `validateOptionalField`** del engine de sess; aparece 8 veces.
3. **Centralizar comparaciones por `JSON.stringify`** en un `equalsByJson`
en `libs/objs/`. Hoy aparece en sess y stor.
4. **Mover `setCached` a un helper `cacheKeyAtTime(input, scope)`** en perm
para fijar la scope-key al inicio del check (cierra C1).
5. **Unificar el patrón de listeners por función estable.** `stor`, `cach` y
`conn` lo hacen distinto; converger a "el caller guarda el detacher".
6. **Romper la dependencia `aapp ← stor`** en mensajes/logger category;
`stor` debe exponer su propio helper.
7. **Partir `auth/consts.ts`** en sub-archivos por dominio (cookies, methods,
events, errors). Importar lo que se usa, no cargar 80 constantes por
módulo.
8. **Documentar adapter contract** (auth/store) y separar `findFlowForUpdate`
de `findFlowByStateHash`.
9. **Pulir el README de `arts/`** para añadir leyenda "Adapters", "Hooks",
"SSR" y enlazar los `DESIGN_*.md`.
---
## Simplificaciones recomendadas
1. **Eliminar `untrack` defensivos** en `cach` que no responden a un caso
concreto (M11).
2. **Inline `mapSendToJoinResult`** y `mergeHeaders` cuando se usen una vez.
3. **Reducir el surface de `Cache.snapshot()`**: hoy expone `lastEvent`,
`eventCount`, `loading`, `lastError`, `disposed`. ¿Qué consumidor real
usa `eventCount`? Si solo lo usa el test page, mover a un helper de
debug.
4. **Unificar nombres**: `loading` vs `loadingCount`, `lastError` vs
`errorCell`, `current` vs `snapshot()`. La regla "loading siempre boolean,
lastError siempre `TError | null`" ya está en el README; aplicarla en los
internals.
5. **Rebajar `mono-lang` a un export de funciones**, no un Active completo —
hoy implementa `ActiveLang` solo para el cast. Se podría aceptar `null`
en `aapp.Lang` y guardarlo detrás de un proxy.
6. **Quitar el wrapper `safeParse`** del test page de http; el patrón
"intenta JSON.parse con fallback string" es trivial y oculta errores.
7. **Devolver el detacher de `onChange`** en `EngineLogger` para alinearse
con el resto, aunque hoy no haya listeners.
---
## Optimizaciones recomendadas
1. **Permisos**: cachear `decisionKey` por scope al inicio del check (resuelve
C1 y mejora rendimiento en aplicaciones con muchas checks por evento).
2. **HTTP retries**: cachear el body serializado *y* los headers cuando no
cambian entre intentos (M10).
3. **Storage `read()`**: comparar `prev === next` por `Object.is` antes de
dispatch — evita re-render en cadena cuando un setItem coincide con el
valor actual.
4. **Cache `mergeDefaults`** evita recomputar `JSON.stringify(defaults)`
cada lectura. Si se cumple igualdad estructural, dedupe.
5. **`SvelteMap`/`SvelteSet`** en `aapp` (`sessionBridgeListeners`,
`connectionRegistries`) están bien marcados como no-reactivos, pero hay
sitios en `stor` (`userSubs`) y `perm` (`pending`) donde plain `Map` es
suficiente — sub-agente reportó que algunos son `SvelteMap`. Verificar
y bajar a Map donde no haya consumo en templates.
6. **Compactar `vitals.ts` config factories** (logr) — patrón repetido
`levelsAtLeast(...)` en cada transport.
7. **`fmts` Currency cache**: `Map + JSON.stringify(options)` por entrada
produce keys grandes; un `Map<locale, Map<code, Map<optionsKey, Intl>>>`
es más rápido y barato.
---
## Incoherencias de arquitectura
1. **`aapp` sabe demasiado de `stor`**. Importa `LOGGER_CATEGORY` y un
message builder de stor. La capa de composición debería ser ciega al
formato de los errores de los proveedores.
2. **`auth` cliente y server compartidos vía `libs/auth`** — bien, pero
`helpers.ts` (cliente) llama a tags que solo usa `aapp`. Mover a `aapp`
o a `libs/svrs/auth`.
3. **`cach` cliente vive en `arts/cach` pero el engine real está en
`svrs/cach`**. El active es un wrapper. Coherente con el patrón
"auth/perm/cach se parten en svrs+arts" — pero el README de `arts/cach`
no menciona la dependencia explícita a `$svrs/cach`. Confuso para un
nuevo dev.
4. **`AuthRateLimitPort` en `svrs/auth/rate-limit.ts` exportado pero no
integrado** (C7). Rompe la promesa "todos los puertos usados".
5. **Memory adapter en `svrs/auth/adapters/memory.ts` no marcado como
tests-only** (C8). Coherencia con expectativa producción/test.
6. **`Sess` exige `App.createActiveSession` como factory una sola vez**, pero
`Connections` no (M16). Inconsistencia.
7. **`mono-lang` rompe la garantía de tipo**. Cast `as unknown as
ActiveLang<S>` significa que el tipo del `App.Lang` no es de fiar.
Coherencia con el contrato "App.Lang siempre tipado por schema".
8. **Constantes de "categoría logger"** son strings cortos por artefacto
(`'sium'`, `'sess'`, `'auth.client'`, `'cache'`). El propio `aapp.ts`
incluye `auth.client` y `cache` con punto, mientras `sess` es plano.
Convención no documentada.
---
## Tests faltantes
### Críticos
- **`arts/perm/test`** (188 LOC, 1 archivo): tests para C1 (race
cross-actor), `invalidate(scope)` con scope correcto/incorrecto, dedup de
`pending` con error y reintento.
- **`arts/cach/test`** (120 LOC, 1 archivo): TTL expiry, stale-while-revalidate
con error en fetcher, race entre `set` y `query`, integración con
`$stor`.
- **`arts/auth/test`** (161 LOC, 1 archivo): CSRF flow completo (rechazo si
cookie/token no coinciden, expiración), sign-out con red caída (M13),
`requestPasswordReset` y `completePasswordReset`, `revokeDevice`.
- **`svrs/auth/test`** (3 archivos): refresh rotation reuse window, OAuth
state-hash collision, MFA challenge expirado, rate-limit (cuando se
cablee).
### Importantes
- **`arts/conn/test`** (2 archivos): WebSocket transport mockeado, ack
timeout, reconnect con backoff, disposal idempotente.
- **`arts/fmts/test`** (28 LOC) y **`arts/fend/test`** (63 LOC): casi vacíos.
Cubrir locale switching, currency rounding, dir auto-derivation.
- **`arts/timr/test`** (3 archivos): backoff formula, scope cancellation,
`awaitTask:false` fire-and-forget.
- **`arts/aapp/test`** (5 archivos): orden de disposal, idempotencia, doble
factory.
- **`arts/adom/test`** (5 archivos): roving focus keyboard, viewport debounce,
scroll lock multi-claim.
### Edge cases
- Sess: `expiresAt - issuedAt < 1`, `generation > Number.MAX_SAFE_INTEGER`,
refresh y revoke concurrentes.
- HTTP: Retry-After con segundos vs HTTP-date, abort en mitad de retry,
`bodySchema` y `schema` en conflicto.
- Stor: cuota excedida, envelope corrupto, migrate fallido en cadena.
---
## Preguntas abiertas
1. **¿Qué propiedades de "scope" debería tener `cach.invalidate({tags})`
cuando se llama desde `aapp` por evento de auth?** Ahora se pierde por
`Cache.clear()`. ¿Decisión consciente o pendiente?
2. **¿Es `mono-lang` parte estable del API público o un fallback interno?**
El cast unsafe sugiere lo segundo, pero `index.ts` lo exporta.
3. **¿Cuál es la promesa de "Active" en cuanto a SSR?** `arts/README.md`
dice "lives in `.svelte.ts` because it owns `$state`" pero no aclara qué
funciones son seguras en `+page.server.ts`. Hay implementaciones con
guardas (`fend`, `stor`) y otras sin (`logr` con `beforeunload`). ¿Cuál
es la regla?
4. **¿`AuthRateLimitPort` queda fuera del MVP?** Si sí, no exportar en el
barrel para evitar la falsa impresión.
5. **¿Memory adapters de `svrs/auth/cach/perm` están pensados para
producción multi-instancia?** Si no, marcarlos.
6. **`Cache.clear()` durante una `query()` en vuelo: ¿debería abortar la
query?** (M12). Decisión semántica.
7. **`Sess.dispose()` durante un `refresh()` en vuelo**: ¿la promesa
resuelve con `SessDisposedError` o con `SKIPPED`?
8. **¿`hydrate(snapshot)` en perm debe abortar `pending`?** (M15).
---
## Veredicto
**Lo sólido**
- Convenciones del framework: `ActiveEngine`, factories `createEngineXxx` /
`createActiveXxx`, dispose idempotente, no magic strings (en su mayoría),
named exports, sin barrels con `export *`. Esto es difícil de mantener a
escala y se nota el cuidado.
- `lang`, `logr`, `sium`, `stor`, `sess` están en muy buen estado, con
tests serios (≥700 LOC cada uno) y READMEs alineados.
- `timr` (locked-in design) y `http` están limpios y bien encapsulados.
- Las decisiones documentadas en MEMORY.md (sess actor extension,
`App.createSiumEngine` zero-arg, no `App.Stores`) están correctamente
reflejadas en el código.
**Lo que frena la calidad**
- La integración auth/perm/cach está a medias: `aapp` tira de un cordel
fácil (`Cache.clear()`) en vez de cablear bien identidad → cache → permisos.
El resultado es un comportamiento conservador pero inseguro en bordes
(C1, C2, C3).
- Server-authoritative auth tiene gaps importantes en producción: sin rate
limiting (C7), MFA stub (C5), PKCE no validado server-side (C6), memory
adapter sin warning (C8).
- Cobertura de tests muy desigual: lo más crítico (auth, perm, cach, fmts,
fend) es lo menos cubierto.
- Pequeños bugs de ergonomía dispersos: nombres de método incorrectos en
`ensureLive` (C4), `untrack` defensivos sin documentar, casts forzados que
ocultan semánticas reales.
**Orden de actuación sugerido**
1. **Sprint de seguridad operativa** (1-2 semanas):
- Cablear `AuthRateLimitPort` en sign-in/sign-up/reset/oauth (C7).
- Pasar el `verifier` PKCE al provider y validarlo server-side (C6).
- Marcar memory adapters como dev/test only en README + warning runtime (C8).
- Documentar SECURITY.md con el flujo completo (CSRF, OAuth state,
refresh rotation, MFA).
- Cambiar SameSite default CSRF a `strict` (M2).
2. **Sprint de wiring de identidad** (1 semana):
- Cerrar C1 (race en perm).
- Cerrar C2 (perm.invalidate al cambiar identidad).
- Cerrar C3 (cach.invalidate respeta tags).
- M12 (Cache.clear con generation guard).
- M15 (perm.hydrate/invalidate aborta pending).
3. **Sprint de pulido** (1 semana):
- C4 (constantes de método correctas).
- M4 (extraer `validateOptionalField` en sess).
- M5/M11 (limpiar coupling y untrack defensivos).
- C5: o implementar MFA verify, o quitarlo del export.
- Sub-archivos en `auth/consts.ts`.
4. **Sprint de tests** (≥1 semana, dependiendo de la profundidad):
- Subir cobertura de `auth/test`, `perm/test`, `cach/test`, `fmts/test`
y `fend/test` al nivel de `sium/test` y `stor/test`.
Después de eso el framework estaría sólido y listo para usuarios externos.
Antes, el escaparate (lang/logr/sium/sess/stor) no refleja el estado real
de los flancos de seguridad.
---
## Falsos positivos descartados
(Reportados por sub-agentes y verificados como incorrectos al leer el código.)
- **PKCE construcción incorrecta** (`oauth/pkce.ts`). El sub-agente afirmó
que `hash(verifier)` no era SHA256/base64url. Verificado: `hashAuthToken`
es `base64URL(sha256(token))`, lo cual es exactamente la transformación
S256 de RFC 7636. La queja real es C6 (no se valida en callback), no la
construcción.
- **Refresh rotation no transaccional**. Verificado: `findRefreshTokenForUpdate`
+ `rotateRefreshToken` están diseñados para correr atómicamente — el
contrato lo asume y un adapter SQL real lo implementa. La queja real es
C8 (memory adapter no documentado como inseguro).
- **`stateHash as AuthFlowId` permite cualquier hash**. Verificado: el store
tiene fallback explícito de búsqueda por stateHash; tipos sufren pero no
hay bypass de seguridad. La queja válida es M3 (separar la API).
- **Test directories vacíos** (conn, perm, etc.). Verificado: todos tienen
≥1 archivo. La queja real es la cobertura desigual, no la ausencia.
- **`adoptServer` SSR safety**. El sub-agente sugirió listener leak; el
código (engine-session.ts) protege con guards `typeof BroadcastChannel`.
- **`storage.adapter.removeItem` con `null`**. Reportado como riesgo; en
realidad la API es estándar `Storage` y removeItem(key) sin valor.

@ -0,0 +1,17 @@
# Next Steps
Estado al cierre:
- Suite unitaria verde: `npm test` -> 103 archivos, 1189 tests.
- `fmts` verde: `npx vitest run src/arts/fmts` -> 14 archivos, 39 tests.
- No tocar `src/web/routes/temp/` hasta decidir que hacer con esa pagina.
- No commitear `.idea/`, `.claude/` ni `.opencode/`.
Pendiente para manana:
- Ejecutar una pasada completa sobre `/test/ecosystem` en navegador y corregir cualquier fallo real de integracion.
- Revisar la adopcion final del contrato comun `Logger` / diagnostics en todos los modulos, sin acoplar artefactos a `arts/logr`.
- Continuar la reduccion de archivos grandes y boilerplate: prioridad `conn`, `auth`, `cach` y cualquier wrapper activo repetitivo.
- Ampliar tests de integracion cruzada: `auth + sess + perm + cach + http + stor + fmts + conn + timr + logr`.
- Revisar documentacion raiz de `arts`, `aapp`, `auth`, `cach`, `perm`, `conn` y `fmts` para que refleje el estado real del framework.
- Decidir que hacer con la pagina temporal que bloquea `npm run check`; mientras tanto, validar con `npm test` y tests focalizados.

@ -43,6 +43,67 @@ Conventions:
`ActiveConnections.connection()`, etc.) own those entries and dispose them
when the root is disposed.
## Logger And Diagnostics Contract
Every artifact that emits runtime information follows the same two-layer
contract:
```ts
import type { DiagnosticEvent, Diagnostics, Logger } from '$libs/logr';
```
- Public options use `logger?: Logger`. Do not create artifact-local logger
interfaces such as `LangLogger`, `TimerLogger` or `ConnectionLogger`.
- The root logger implementation is `EngineLogger` from `$logr`; it extends the
shared `Logger` contract from `$libs/logr`.
- Artifact code defines `<Artifact>Diagnostics` with
`create<Artifact>Diagnostics(logger?)` and emits catalogued events for
internal diagnostics.
- Diagnostic event names live in the artifact `consts.ts` as
`*_DIAGNOSTIC_EVENTS`. Messages live in `errors.ts` or `consts.ts`, never as
inline strings in runtime logic.
- `Diagnostics<TEvent>` always exposes `{ logger, emit(event) }`. The `logger`
property is the common `Logger`, so modules that need an ad-hoc `info` or
`error` still have the full logger without inventing a second interface.
- Level routing is controlled by the logger/transports via the existing
per-level enablement map, not by module-specific severity systems.
Typical shape:
```ts
export const HTTP_DIAGNOSTIC_EVENTS = {
REQUEST: 'http.request',
NETWORK_ERROR: 'http.network_error'
} as const;
export function createHttpDiagnostics(logger?: Logger): HttpDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: HTTP_DIAGNOSTIC_LOGS
});
}
```
This gives every module the same path to Sentry, Loki, Datadog, console,
test-capture transports or any future sink: inject one `Logger`, emit typed
diagnostic events, let `logr` route.
## Error Contract
Errors follow the same rule: strings are centralized, and public programmer
errors are typed.
- Error messages and error names live in `errors.ts` or `consts.ts`.
- Runtime code must not throw inline string/template errors outside tests or
vendored code.
- Public programmer errors use artifact-specific classes and guards:
`SessDisposedError`, `ConnInvalidNameError`, `UnitsUnknownUnitError`, etc.
- Expected runtime failures should be returned as tagged data/results when the
artifact already has such a contract (`http`, `conn`, `perm`, `cach`).
- Validation failures are data (`SiumValidationError.issues`) and diagnostics
are emitted separately when a logger is injected.
## Map
| Artifact | Layer(s) | Purpose | Depends on |

@ -36,7 +36,7 @@ App.dispose();
| `App.Formats` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) |
| `App.Frontend` | yes | real with default theme/mode/density |
| `App.Dom` | yes | real with default breakpoints |
| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; `onError` is wired through `Logger.error('storage', ...)` |
| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; storage diagnostics are wired through the shared Logger |
| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry }` |
| `App.Timers` | yes | `ActiveTimers` scheduler owned by App. Used by artifacts that need keyed runtime timers (`sess` auto-refresh, `conn` reconnect/heartbeat/ack) and disposed by `App.dispose()` |
| `App.Cache` | yes | `ActiveCache` backed by memory by default. Configure `cache: { adapter, policies, scopeResolver }` for persistence, custom policies or tenant/actor/permission-aware keys |
@ -112,7 +112,7 @@ option.
provided; mono otherwise. Both wire `Lang.setLogger` to the shared
Logger.
3. **Storage** — built next so Frontend can read persisted preferences
before construction. Storage `onError` is wired to `Logger.error`.
before construction. Storage diagnostics are wired to the shared Logger.
4. **Formats** — built with a `localeSource` derived from Lang.
5. **Dom** — built before Frontend.
6. **Frontend** — receives Dom and the same `localeSource`. When
@ -303,10 +303,10 @@ const locale = App.Storage.entry('locale', 'es', {
});
```
Storage `onError` is wired automatically: failures land in
`App.Logger.error('storage', ...)` with `{ adapter, key, op, error }` in the
context. See `$stor/README.md` for the full API (adapters, envelope,
versioning, validation).
Storage diagnostics are wired automatically through `StorageDiagnostics` and
the shared `App.Logger`; failures include `{ adapter, key, fullKey, op, error }`
in the diagnostic context. See `$stor/README.md` for the full API (adapters,
envelope, versioning, validation).
### Reactive keys

@ -6,6 +6,7 @@ import {
type ActiveAuthOptions
} from '$auth';
import { createActiveCache } from '$cach';
import { CACHE_SCOPE_PUBLIC } from '$libs/cach';
import { createActiveConnections as createActiveConnectionsRegistry } from '$conn';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn';
import { createActiveFrontend } from '$fend';
@ -28,11 +29,7 @@ import {
type EngineSessionOptions
} from '$sess';
import { createEngineSium } from '$sium';
import {
createActiveStorage,
LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY,
type StorageErrorContext
} from '$stor';
import { createActiveStorage } from '$stor';
import { createActiveTimers } from '$timr';
import {
@ -44,8 +41,7 @@ import {
APP_ERROR_ALREADY_CREATED_PERMISSIONS,
APP_ERROR_ALREADY_CREATED_AUTH,
APP_ERROR_ALREADY_CREATED_SESSION,
APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED,
APP_STORAGE_ERROR_MESSAGE
APP_ERROR_CREATE_PERMISSIONS_ENDPOINT_REQUIRED
} from './consts.ts';
import { AappAlreadyCreatedError } from './errors.ts';
import type { ActiveApp, ActiveAppOptions } from './types.ts';
@ -74,12 +70,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
const Storage = createActiveStorage({
adapter: options.storage?.adapter,
namespace: options.storage?.namespace,
onError: (ctx: StorageErrorContext) => {
Logger.error(STORAGE_LOGGER_CATEGORY, APP_STORAGE_ERROR_MESSAGE(ctx.op, ctx.fullKey), {
error: ctx.error,
context: { adapter: ctx.adapter, key: ctx.key }
});
}
logger: Logger
});
const localeSource = {
@ -234,16 +225,29 @@ export function createActiveApp<S extends LangNode = LangNode>(
return built;
},
createActiveAuth(authOptions: Omit<ActiveAuthOptions, 'http' | 'cach'> = {}): ActiveAuth {
createActiveAuth(
authOptions: Omit<ActiveAuthOptions, 'http' | 'cach' | 'logger'> = {}
): ActiveAuth {
if (Auth !== undefined) {
throw new AappAlreadyCreatedError(APP_ERROR_ALREADY_CREATED_AUTH);
}
const built = createActiveAuth({
...options.auth,
...authOptions,
logger: Logger,
http: createEngineHttpAuthClient(Http),
cach: {
invalidate: () => Cache.clear()
async invalidate(input) {
Permissions?.invalidate();
await Promise.all(
input.tags.map((tag) =>
Cache.invalidate({
tag,
scope: CACHE_SCOPE_PUBLIC
})
)
);
}
}
});
Auth = built;

@ -2,9 +2,6 @@ export const LOGGER_CATEGORY = 'app';
export const APP_ERROR_NAME_ALREADY_CREATED = 'AappAlreadyCreatedError';
export const APP_STORAGE_ERROR_MESSAGE = (op: string, fullKey: string): string =>
`${op} on "${fullKey}"`;
export const APP_ERROR_ALREADY_CREATED_SESSION =
'[aapp] App.createActiveSession() called twice — only one session per App.';

@ -20,13 +20,28 @@ import { LogLevel, type LogEntry } from '$logr';
import type { LangNode } from '$lang';
import { MONO_LANG_CATEGORY } from '$lang/mono-lang.svelte';
import { createMockTransport } from '$conn';
import { AUTH_AAL, AUTH_SESSION_STATUSES } from '$libs/auth';
import { CACHE_EVENT_INVALIDATE } from '$libs/cach';
import {
AUTH_AAL,
AUTH_CACHE_TAGS,
AUTH_HEADER_NAMES,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES
} from '$libs/auth';
import { PERMISSION_EFFECT_ALLOW } from '$libs/perm';
const schema = {
greeting: { es: 'Hola', en: 'Hello', 'es-MX': 'Qué onda' },
cart: { es: 'Carrito', en: 'Cart' }
} satisfies LangNode;
function json(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { [AUTH_HEADER_NAMES.CONTENT_TYPE]: 'application/json' }
});
}
describe('createActiveApp — composition', () => {
it('exposes Logger, Lang, Formats, Frontend, Dom (no Sium)', () => {
const App = createActiveApp({
@ -156,6 +171,57 @@ describe('createActiveApp — composition', () => {
expect(App.Auth).toBeUndefined();
});
it('auth identity changes invalidate permissions and auth cache tags', async () => {
const invalidatedTags: string[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const path = new URL(url, 'https://active.test').pathname;
if (path === AUTH_ROUTE_PATHS.CSRF) return json({ token: 'csrf-token', expiresAt: 1 });
if (path === AUTH_ROUTE_PATHS.SIGN_IN_PASSWORD) {
return json({
current: {
session: {
status: AUTH_SESSION_STATUSES.AUTHENTICATED,
aal: AUTH_AAL.SINGLE_FACTOR,
amr: ['pwd']
}
}
});
}
if (path === '/permissions/check') {
return json({ effect: PERMISSION_EFFECT_ALLOW, policy: 'remote.allow' });
}
return new Response(null, { status: 404 });
}) as typeof fetch;
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
http: { baseUrl: 'https://active.test', fetch: fetcher },
cache: {
onEvent(event) {
if (event.type === CACHE_EVENT_INVALIDATE) invalidatedTags.push(...(event.tags ?? []));
}
}
});
const Permissions = App.createActivePermissions({
endpoint: 'https://active.test/permissions'
});
const Auth = App.createActiveAuth();
await Permissions.check({ action: 'post.read', resource: { type: 'post', id: 'p1' } });
expect(Permissions.size).toBe(1);
await Auth.signInPassword({ identifier: 'ada@example.com', password: 'correct horse' });
expect(Permissions.size).toBe(0);
expect(invalidatedTags).toEqual([
AUTH_CACHE_TAGS.AUTH_CURRENT,
AUTH_CACHE_TAGS.AUTH_DEVICES,
AUTH_CACHE_TAGS.AUTH_FACTORS
]);
App.dispose();
});
it('dispose() flushes buffered transports', () => {
const writes: string[] = [];
const App = createActiveApp({

@ -136,7 +136,7 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
* automatically; the authoritative auth engine still lives server-side
* under `$svrs/auth`.
*/
auth?: Omit<ActiveAuthOptions, 'http' | 'cach'>;
auth?: Omit<ActiveAuthOptions, 'http' | 'cach' | 'logger'>;
}
/**
@ -252,7 +252,7 @@ export interface ActiveApp<S extends LangNode = LangNode> {
* reflects `/api/auth/*` state, sends CSRF headers and exposes pending /
* error state for UI.
*/
createActiveAuth(options?: Omit<ActiveAuthOptions, 'http' | 'cach'>): ActiveAuth;
createActiveAuth(options?: Omit<ActiveAuthOptions, 'http' | 'cach' | 'logger'>): ActiveAuth;
/**
* The active session, when one has been built via

@ -2,11 +2,13 @@ import {
AUTH_ERROR_CODES,
AUTH_EVENT_NAMES,
AUTH_HEADER_NAMES,
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_METHOD_CLEAR_ERROR,
AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION,
AUTH_METHOD_COMPLETE_PASSWORD_RESET,
AUTH_METHOD_LIST_DEVICES,
AUTH_METHOD_LOAD_CURRENT,
AUTH_METHOD_ON_CHANGE,
AUTH_METHOD_REQUEST_EMAIL_VERIFICATION,
AUTH_METHOD_REQUEST_PASSWORD_RESET,
AUTH_METHOD_REVOKE_DEVICE,
@ -21,6 +23,7 @@ import {
} from './consts.ts';
import { authCacheTagsForIdentity, createAnonymousAuthCurrent } from '$libs/auth/helpers';
import { AuthDisposedError, AuthInvalidResponseError, isAuthRequestFailedError } from './errors.ts';
import { createAuthClientDiagnostics, emitAuthClientDiagnostic } from './diagnostics.ts';
import type {
AuthClientSafeError,
AuthCurrentView,
@ -40,6 +43,7 @@ import type {
} from './types';
export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const diagnostics = createAuthClientDiagnostics(options.logger);
let current = $state<AuthCurrentView>(options.initial ?? createAnonymousAuthCurrent());
let loading = $state(false);
let lastError = $state<AuthClientSafeError | null>(null);
@ -75,7 +79,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
AUTH_METHOD_SIGN_IN_PASSWORD
);
current = result.current;
await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED);
notify();
return current;
});
@ -93,7 +97,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
AUTH_METHOD_SIGN_UP_PASSWORD
);
current = result.current;
await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED);
notify();
return current;
});
@ -104,7 +108,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const csrf = await ensureCsrf(options);
await options.http.post(AUTH_ROUTE_PATHS.SIGN_OUT, undefined, csrfHeaders(csrf));
current = createAnonymousAuthCurrent();
await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED);
notify();
});
}
@ -114,7 +118,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const csrf = await ensureCsrf(options);
await options.http.post(AUTH_ROUTE_PATHS.SIGN_OUT_GLOBAL, undefined, csrfHeaders(csrf));
current = createAnonymousAuthCurrent();
await invalidateClientCache(options, AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED);
notify();
});
}
@ -131,7 +135,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const csrf = await ensureCsrf(options);
await options.http.post(AUTH_ROUTE_PATHS.PASSWORD_RESET_COMPLETE, input, csrfHeaders(csrf));
current = createAnonymousAuthCurrent();
await invalidateClientCache(options, AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED);
notify();
});
}
@ -152,7 +156,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const csrf = await ensureCsrf(options);
await options.http.post(AUTH_ROUTE_PATHS.EMAIL_VERIFY_COMPLETE, input, csrfHeaders(csrf));
await loadCurrent();
await invalidateClientCache(options, AUTH_EVENT_NAMES.EMAIL_VERIFIED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.EMAIL_VERIFIED);
});
}
@ -170,7 +174,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const csrf = await ensureCsrf(options);
await options.http.post(AUTH_ROUTE_PATHS.DEVICE_REVOKE, input, csrfHeaders(csrf));
await loadCurrent();
await invalidateClientCache(options, AUTH_EVENT_NAMES.DEVICE_REVOKED);
await invalidateClientCache(options, diagnostics, AUTH_EVENT_NAMES.DEVICE_REVOKED);
});
}
@ -182,6 +186,10 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
return await operation();
} catch (error) {
lastError = normalizeClientError(error);
emitAuthClientDiagnostic(diagnostics, AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED, {
method,
error
});
throw error;
} finally {
loading = false;
@ -230,7 +238,7 @@ export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
return current;
},
onChange(listener) {
ensureLive(AUTH_METHOD_LOAD_CURRENT);
ensureLive(AUTH_METHOD_ON_CHANGE);
listeners.add(listener);
listener(current);
return () => {
@ -274,12 +282,18 @@ function normalizeClientError(error: unknown): AuthClientSafeError {
async function invalidateClientCache(
options: ActiveAuthOptions,
diagnostics: ReturnType<typeof createAuthClientDiagnostics>,
reason: AuthEventName
): Promise<void> {
if (!options.cach) return;
try {
await options.cach.invalidate({ tags: authCacheTagsForIdentity(), reason });
} catch (error) {
emitAuthClientDiagnostic(
diagnostics,
AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED,
{ reason, error }
);
options.onCacheError?.(error);
}
}

@ -14,6 +14,15 @@ export {
export const LOGGER_CATEGORY = 'auth';
export const AUTH_CLIENT_DIAGNOSTIC_EVENTS = {
OPERATION_FAILED: 'auth.client.operation_failed',
CACHE_INVALIDATION_FAILED: 'auth.client.cache_invalidation_failed'
} as const;
export const AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED = 'auth client operation failed';
export const AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED =
'auth client cache invalidation failed';
export const AUTH_METHOD_LOAD_CURRENT = 'loadCurrent';
export const AUTH_METHOD_SIGN_IN_PASSWORD = 'signInPassword';
export const AUTH_METHOD_SIGN_UP_PASSWORD = 'signUpPassword';
@ -26,6 +35,7 @@ export const AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION = 'completeEmailVerificatio
export const AUTH_METHOD_LIST_DEVICES = 'listDevices';
export const AUTH_METHOD_REVOKE_DEVICE = 'revokeDevice';
export const AUTH_METHOD_CLEAR_ERROR = 'clearError';
export const AUTH_METHOD_ON_CHANGE = 'onChange';
export const AUTH_ERROR_NAME_REQUEST_FAILED = 'AuthRequestFailedError';
export const AUTH_ERROR_NAME_DISPOSED = 'AuthDisposedError';

@ -0,0 +1,61 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logr';
import {
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED,
AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED,
LOGGER_CATEGORY
} from './consts.ts';
import type { AuthEventName } from '$libs/auth/types';
export type AuthClientDiagnosticType =
(typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS];
export interface AuthClientDiagnosticMeta {
readonly method?: string;
readonly reason?: AuthEventName;
readonly error?: unknown;
}
export type AuthClientDiagnosticEvent = DiagnosticEvent<
AuthClientDiagnosticType,
AuthClientDiagnosticMeta
>;
export type AuthClientDiagnostics = Diagnostics<AuthClientDiagnosticEvent>;
const AUTH_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog<AuthClientDiagnosticEvent> = {
[AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: {
level: LogLevel.WARN,
message: AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED
},
[AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED]: {
level: LogLevel.WARN,
message: AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED
}
};
export function createAuthClientDiagnostics(logger?: Logger): AuthClientDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: AUTH_CLIENT_DIAGNOSTIC_LOGS
});
}
export function emitAuthClientDiagnostic(
diagnostics: AuthClientDiagnostics,
type: AuthClientDiagnosticType,
meta: AuthClientDiagnosticMeta
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
type,
meta
});
}

@ -4,6 +4,9 @@ export { AUTH_EVENT_NAMES } from './events.ts';
export { createInMemoryAuthActiveSync } from './sync.ts';
export {
AUTH_CONTENT_TYPES,
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED,
AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED,
AUTH_COOKIE_NAMES,
AUTH_ERROR_CODES,
AUTH_ERROR_MSG_DISPOSED_SUFFIX,
@ -21,6 +24,7 @@ export {
AUTH_METHOD_COMPLETE_PASSWORD_RESET,
AUTH_METHOD_LIST_DEVICES,
AUTH_METHOD_LOAD_CURRENT,
AUTH_METHOD_ON_CHANGE,
AUTH_METHOD_REQUEST_EMAIL_VERIFICATION,
AUTH_METHOD_REQUEST_PASSWORD_RESET,
AUTH_METHOD_REVOKE_DEVICE,
@ -34,6 +38,7 @@ export {
AUTH_STORAGE_KEYS,
LOGGER_CATEGORY
} from './consts.ts';
export { createAuthClientDiagnostics, emitAuthClientDiagnostic } from './diagnostics.ts';
export {
AuthDisposedError,
AuthInvalidResponseError,
@ -43,6 +48,12 @@ export {
isAuthRequestFailedError
} from './errors.ts';
export type { AuthEventName } from './events.ts';
export type {
AuthClientDiagnosticEvent,
AuthClientDiagnosticMeta,
AuthClientDiagnostics,
AuthClientDiagnosticType
} from './diagnostics.ts';
export type { AuthActiveSyncPort } from './sync.ts';
export type {
ActiveAuth,

@ -16,12 +16,14 @@ import type {
AuthClientHttpPort,
AuthClientStoragePort
} from '$libs/auth/contracts';
import type { Logger } from '$libs/logr';
export interface ActiveAuthOptions {
readonly http: AuthClientHttpPort;
readonly cach?: AuthClientCachPort;
readonly stor?: AuthClientStoragePort;
readonly initial?: AuthCurrentView;
readonly logger?: Logger;
readonly onCacheError?: (error: unknown) => void;
}

@ -1,4 +1,3 @@
import { untrack } from 'svelte';
import { CACHE_EVENT_ALL, type CacheClock, type CacheEvent } from '$libs/cach';
import { createEngineCache } from '$svrs/cach';
import {
@ -12,6 +11,7 @@ import {
CACHE_ACTIVE_STATUS_REFRESHING,
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
CACHE_METHOD_CLEAR,
CACHE_METHOD_ENTRY,
CACHE_METHOD_EXPLAIN,
@ -24,6 +24,7 @@ import {
CACHE_METHOD_STATS
} from './consts.ts';
import { disposedCacheMessage } from '$svrs/cach';
import { createActiveCacheDiagnostics, emitActiveCacheDiagnostic } from './diagnostics.ts';
import { CachActiveEntryDisposedError, CachDisposedError } from './errors.ts';
import type {
ActiveCache,
@ -37,6 +38,7 @@ import type {
} from './types.ts';
export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache {
const diagnostics = createActiveCacheDiagnostics(options.logger);
const engine = createEngineCache(options);
const clock = options.clock ?? systemClock;
let disposed = false;
@ -54,16 +56,16 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache
const offEvents = engine.on(CACHE_EVENT_ALL, (event) => {
lastEventCell = event;
eventCountCell = untrack(() => eventCountCell) + 1;
eventCountCell += 1;
notify();
});
function updateLoading(delta: number): void {
loadingCount = Math.max(0, untrack(() => loadingCount) + delta);
loadingCount = Math.max(0, loadingCount + delta);
notify();
}
async function track<T>(task: () => Promise<T>): Promise<T> {
async function track<T>(method: string, task: () => Promise<T>): Promise<T> {
updateLoading(1);
try {
const value = await task();
@ -72,6 +74,10 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache
return value;
} catch (error) {
lastErrorCell = normalizeActiveCacheError(error);
emitActiveCacheDiagnostic(diagnostics, CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED, {
method,
error
});
notify();
throw error;
} finally {
@ -85,9 +91,11 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache
entryOptions,
clock,
{
query: (queryOptions) => track(() => engine.query(queryOptions)),
set: (key, value, setOptions) => track(() => engine.set(key, value, setOptions)),
invalidate: (invalidateOptions) => track(() => engine.invalidate(invalidateOptions))
query: (queryOptions) => track(CACHE_METHOD_QUERY, () => engine.query(queryOptions)),
set: (key, value, setOptions) =>
track(CACHE_METHOD_SET, () => engine.set(key, value, setOptions)),
invalidate: (invalidateOptions) =>
track(CACHE_METHOD_INVALIDATE, () => engine.invalidate(invalidateOptions))
},
() => {
entries.delete(built as ActiveCacheEntry<unknown>);
@ -134,27 +142,27 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache
},
query(queryOptions) {
ensureLive(CACHE_METHOD_QUERY);
return track(() => engine.query(queryOptions));
return track(CACHE_METHOD_QUERY, () => engine.query(queryOptions));
},
get(key, getOptions) {
ensureLive(CACHE_METHOD_GET);
return track(() => engine.get(key, getOptions));
return track(CACHE_METHOD_GET, () => engine.get(key, getOptions));
},
set(key, value, setOptions) {
ensureLive(CACHE_METHOD_SET);
return track(() => engine.set(key, value, setOptions));
return track(CACHE_METHOD_SET, () => engine.set(key, value, setOptions));
},
invalidate(invalidateOptions) {
ensureLive(CACHE_METHOD_INVALIDATE);
return track(() => engine.invalidate(invalidateOptions));
return track(CACHE_METHOD_INVALIDATE, () => engine.invalidate(invalidateOptions));
},
mutate(mutateOptions) {
ensureLive(CACHE_METHOD_MUTATE);
return track(() => engine.mutate(mutateOptions));
return track(CACHE_METHOD_MUTATE, () => engine.mutate(mutateOptions));
},
explain(key, explainOptions) {
ensureLive(CACHE_METHOD_EXPLAIN);
return track(() => engine.explain(key, explainOptions));
return track(CACHE_METHOD_EXPLAIN, () => engine.explain(key, explainOptions));
},
stats() {
ensureLive(CACHE_METHOD_STATS);
@ -166,7 +174,7 @@ export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache
},
clear() {
ensureLive(CACHE_METHOD_CLEAR);
return track(() => engine.clear());
return track(CACHE_METHOD_CLEAR, () => engine.clear());
},
entry,
snapshot,

@ -53,6 +53,12 @@ export const CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE = 'invalidate';
export const CACHE_ACTIVE_ENTRY_EVENT_SET = 'set';
export const CACHE_ERROR_NAME_ACTIVE_ENTRY_DISPOSED = 'CachActiveEntryDisposedError';
export const CACHE_ACTIVE_DIAGNOSTIC_EVENTS = {
OPERATION_FAILED: 'cach.active.operation_failed'
} as const;
export const CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED = 'active cache operation failed';
export const CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED =
'[cach] ActiveCacheEntry used after dispose().';

@ -0,0 +1,56 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logr';
import type { CacheKey } from '$libs/cach';
import {
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED,
LOGGER_CATEGORY
} from './consts.ts';
export type ActiveCacheDiagnosticType =
(typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS)[keyof typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS];
export interface ActiveCacheDiagnosticMeta {
readonly method: string;
readonly key?: CacheKey;
readonly error?: unknown;
}
export type ActiveCacheDiagnosticEvent = DiagnosticEvent<
ActiveCacheDiagnosticType,
ActiveCacheDiagnosticMeta
>;
export type ActiveCacheDiagnostics = Diagnostics<ActiveCacheDiagnosticEvent>;
const ACTIVE_CACHE_DIAGNOSTIC_LOGS: DiagnosticCatalog<ActiveCacheDiagnosticEvent> = {
[CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: {
level: LogLevel.WARN,
message: CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED
}
};
export function createActiveCacheDiagnostics(logger?: Logger): ActiveCacheDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: ACTIVE_CACHE_DIAGNOSTIC_LOGS
});
}
export function emitActiveCacheDiagnostic(
diagnostics: ActiveCacheDiagnostics,
type: ActiveCacheDiagnosticType,
meta: ActiveCacheDiagnosticMeta
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
type,
meta
});
}

@ -5,6 +5,8 @@ export {
CACHE_ACTIVE_ENTRY_EVENT_REFRESH,
CACHE_ACTIVE_ENTRY_EVENT_SET,
CACHE_ACTIVE_ENTRY_EVENTS,
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED,
CACHE_ACTIVE_STATUS_DEGRADED,
CACHE_ACTIVE_STATUS_ERROR,
CACHE_ACTIVE_STATUS_IDLE,
@ -45,6 +47,7 @@ export {
CACHE_METHOD_STATS,
LOGGER_CATEGORY
} from './consts.ts';
export { createActiveCacheDiagnostics, emitActiveCacheDiagnostic } from './diagnostics.ts';
export {
CachActiveEntryDisposedError,
CachDisposedError,
@ -52,6 +55,12 @@ export {
isCachDisposedError
} from './errors.ts';
export { disposedCacheMessage } from '$svrs/cach';
export type {
ActiveCacheDiagnosticEvent,
ActiveCacheDiagnosticMeta,
ActiveCacheDiagnostics,
ActiveCacheDiagnosticType
} from './diagnostics.ts';
export type {
ActiveCache,
ActiveCacheEntry,
@ -61,7 +70,6 @@ export type {
ActiveCacheEntrySnapshot,
ActiveCacheEntryStatus,
ActiveCacheOptions,
CacheLogger,
EngineCache,
EngineCacheOptions
} from './types.ts';

@ -3,7 +3,7 @@ import type { CacheClock, CacheEvent, CacheKey, QueryOptions, SetOptions } from
import type { EngineCache, EngineCacheOptions } from '$svrs/cach';
import type { ActiveChangeListener, ActiveEngine } from '$libs/active';
export type { CacheLogger, EngineCache, EngineCacheOptions } from '$svrs/cach';
export type { EngineCache, EngineCacheOptions } from '$svrs/cach';
export type ActiveCacheEntryStatus = (typeof CACHE_ACTIVE_STATUSES)[number];

@ -0,0 +1,82 @@
import type { TimerScheduler } from '$timr';
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_REJECTED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
CONNECTION_SEND_REASON_CLOSED,
TIMER_KEY_ACK
} from './consts.ts';
import { timerKey } from './helpers.ts';
import type { ConnectionAckResult, ConnectionFrame, ConnectionSendResult } from './types.ts';
interface PendingAck {
readonly timer: string;
readonly resolve: (result: ConnectionAckResult<unknown>) => void;
}
export interface ConnectionAckRegistry {
wait<TResult>(id: string, timeoutMs: number): Promise<ConnectionAckResult<TResult>>;
resolve(id: string, result: ConnectionAckResult<unknown>): void;
resolveAll(result: ConnectionAckResult<unknown>): void;
resolveFromFrame(frame: ConnectionFrame): void;
mapSendFailure(result: ConnectionSendResult): ConnectionAckResult<unknown>;
}
export function createConnectionAckRegistry(
connectionName: string,
timers: TimerScheduler
): ConnectionAckRegistry {
const pending = new Map<string, PendingAck>();
function resolve(id: string, result: ConnectionAckResult<unknown>): void {
const ack = pending.get(id);
if (ack === undefined) return;
pending.delete(id);
timers.cancel(ack.timer);
ack.resolve(result);
}
return {
wait<TResult>(id: string, timeoutMs: number): Promise<ConnectionAckResult<TResult>> {
const ackTimer = timerKey(connectionName, TIMER_KEY_ACK, id);
return new Promise<ConnectionAckResult<TResult>>((resolveWaiter) => {
pending.set(id, {
timer: ackTimer,
resolve: resolveWaiter as (result: ConnectionAckResult<unknown>) => void
});
timers.schedule(
ackTimer,
timeoutMs,
() => {
resolve(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
},
{ replace: true }
);
});
},
resolve,
resolveAll(result: ConnectionAckResult<unknown>): void {
for (const id of [...pending.keys()]) resolve(id, result);
},
resolveFromFrame(frame: ConnectionFrame): void {
if (frame.replyTo === undefined) return;
if (frame.error !== undefined) {
resolve(frame.replyTo, {
ok: false,
reason: CONNECTION_ACK_REASON_REJECTED,
error: frame.error
});
return;
}
resolve(frame.replyTo, { ok: true, payload: frame.payload });
},
mapSendFailure(result: ConnectionSendResult): ConnectionAckResult<unknown> {
if (result.ok) return { ok: true, payload: undefined };
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error };
}
return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error };
}
};
}

@ -0,0 +1,78 @@
import {
BROWSER_EVENT_ONLINE,
BROWSER_EVENT_VISIBILITY_CHANGE,
CONNECTION_DIAGNOSTIC_EVENTS,
DEFAULT_RECONNECT_ON_ONLINE,
DEFAULT_RECONNECT_ON_VISIBLE,
DOCUMENT_VISIBILITY_VISIBLE
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionReconnectOptions } from './types.ts';
interface BrowserReconnectOptions {
readonly disabled: boolean;
readonly reconnectOptions?: ConnectionReconnectOptions;
readonly shouldReconnect: () => boolean;
readonly reconnect: () => void;
readonly diagnostics: ConnectionDiagnostics;
}
export function wireBrowserReconnect(options: BrowserReconnectOptions): () => void {
if (options.disabled) return () => {};
const detachers: Array<() => void> = [];
const reconnectOnOnline =
options.reconnectOptions?.reconnectOnOnline ?? DEFAULT_RECONNECT_ON_ONLINE;
const reconnectOnVisible =
options.reconnectOptions?.reconnectOnVisible ?? DEFAULT_RECONNECT_ON_VISIBLE;
const target = globalThis as {
addEventListener?: (type: string, listener: () => void) => void;
removeEventListener?: (type: string, listener: () => void) => void;
document?: {
readonly visibilityState?: string;
addEventListener?: (type: string, listener: () => void) => void;
removeEventListener?: (type: string, listener: () => void) => void;
};
};
if (reconnectOnOnline && target.addEventListener && target.removeEventListener) {
const onOnline = (): void => {
if (!options.shouldReconnect()) return;
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT,
{ trigger: BROWSER_EVENT_ONLINE }
);
options.reconnect();
};
target.addEventListener(BROWSER_EVENT_ONLINE, onOnline);
detachers.push(() => {
target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline);
});
}
if (
reconnectOnVisible &&
target.document?.addEventListener &&
target.document.removeEventListener
) {
const onVisible = (): void => {
if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return;
if (!options.shouldReconnect()) return;
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT,
{ trigger: BROWSER_EVENT_VISIBILITY_CHANGE }
);
options.reconnect();
};
target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
detachers.push(() => {
target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
});
}
return () => {
for (const detach of detachers) detach();
detachers.length = 0;
};
}

@ -0,0 +1,85 @@
import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts';
import type {
Connection,
ConnectionChannel,
ConnectionChannelMap,
ConnectionChannelOptions,
ConnectionEventMap,
ConnectionFrame,
ConnectionMessageMeta
} from './types.ts';
export interface ConnectionChannelRegistry<TChannels extends ConnectionChannelMap> {
receive(topic: string, frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
getOrCreate<TEvents extends ConnectionEventMap = ConnectionEventMap>(
name: string,
options: ConnectionChannelOptions,
connection: Connection<Record<string, TEvents>>
): ConnectionChannel<TEvents>;
channels(): readonly ConnectionChannel[];
has(name: string): boolean;
leave(name: string): Promise<void>;
joinConfigured(
reconnect: boolean,
configured: Readonly<Record<string, ConnectionChannelOptions | undefined>>,
connection: Connection<TChannels>
): Promise<void>;
dispose(): void;
}
export function createConnectionChannelRegistry<TChannels extends ConnectionChannelMap>(
reportListenerError: (event: string, error: unknown) => void
): ConnectionChannelRegistry<TChannels> {
const channels = new Map<string, InternalConnectionChannel>();
return {
receive(topic, frame, meta): void {
channels.get(topic)?.receive(frame, meta);
},
getOrCreate<TEvents extends ConnectionEventMap = ConnectionEventMap>(
channelName: string,
channelOptions: ConnectionChannelOptions,
connection: Connection<Record<string, TEvents>>
): ConnectionChannel<TEvents> {
const existing = channels.get(channelName);
if (existing !== undefined) return existing as ConnectionChannel<TEvents>;
const created = createConnectionChannel<TEvents>(channelName, channelOptions, {
connection,
reportListenerError
});
channels.set(channelName, created as InternalConnectionChannel);
return created;
},
channels(): readonly ConnectionChannel[] {
return [...channels.values()];
},
has(name): boolean {
return channels.has(name);
},
async leave(name): Promise<void> {
await channels.get(name)?.leave();
},
async joinConfigured(reconnect, configured, connection): Promise<void> {
const entries = (configured ?? {}) as Record<string, ConnectionChannelOptions | undefined>;
for (const channelName of Object.keys(entries)) {
const configuredOptions = entries[channelName] ?? {};
const current = this.getOrCreate(
channelName,
configuredOptions,
connection as unknown as Connection<Record<string, ConnectionEventMap>>
) as InternalConnectionChannel;
if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) {
await current.join();
}
}
if (!reconnect) return;
for (const current of channels.values()) {
if (current.shouldRejoin) await current.rejoin();
}
},
dispose(): void {
for (const current of channels.values()) current.dispose();
channels.clear();
}
};
}

@ -0,0 +1,59 @@
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_REJECTED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_AUTH_REASON_CLOSED,
CONNECTION_AUTH_REASON_NO_PROVIDER,
CONNECTION_AUTH_REASON_REJECTED,
CONNECTION_AUTH_REASON_TIMEOUT,
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
CONNECTION_FRAME_TYPE_AUTH
} from './consts.ts';
import type {
ConnectionAckResult,
ConnectionAuthPayload,
ConnectionAuthResult,
ConnectionOptions
} from './types.ts';
export type ConnectionAuthProvider = () =>
| ConnectionAuthPayload
| null
| Promise<ConnectionAuthPayload | null>;
export interface ResolvedConnectionAuth {
readonly provider: ConnectionAuthProvider | null;
readonly authType: string;
readonly timeoutMs?: number;
}
export function resolveConnectionAuth(auth: ConnectionOptions['auth']): ResolvedConnectionAuth {
if (typeof auth === 'function') {
return { provider: auth, authType: CONNECTION_FRAME_TYPE_AUTH };
}
return {
provider: auth?.getAuth ?? null,
authType: auth?.authType ?? CONNECTION_FRAME_TYPE_AUTH,
timeoutMs: auth?.timeoutMs
};
}
export function missingConnectionAuthProvider(): ConnectionAuthResult {
return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
}
export function mapConnectionAckToAuthResult(
result: ConnectionAckResult<unknown>
): ConnectionAuthResult {
if (result.ok) return { ok: true };
if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) {
return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error };
}
if (result.reason === CONNECTION_ACK_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error };
}
if (result.reason === CONNECTION_ACK_REASON_REJECTED) {
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error };
}
return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error };
}

@ -0,0 +1,68 @@
import { CONNECTION_DIAGNOSTIC_EVENTS } from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionFrame, ConnectionMessageMeta, ConnectionStateChange } from './types.ts';
type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void;
type StateListener = (change: ConnectionStateChange) => void;
interface ConnectionEventBusOptions {
readonly diagnostics: ConnectionDiagnostics;
}
export interface ConnectionEventBus {
emitState(change: ConnectionStateChange): void;
emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
onState(listener: StateListener): () => void;
onAny(listener: GlobalListener): () => void;
clear(): void;
}
export function createConnectionEventBus(options: ConnectionEventBusOptions): ConnectionEventBus {
const stateListeners = new Set<StateListener>();
const globalListeners = new Set<GlobalListener>();
return {
emitState(change): void {
for (const listener of [...stateListeners]) {
try {
listener(change);
} catch (err) {
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
{ error: err, event: change.to }
);
}
}
},
emitGlobal(frame, meta): void {
for (const listener of [...globalListeners]) {
try {
listener(frame, meta);
} catch (err) {
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
{ error: err, event: frame.type }
);
}
}
},
onState(listener): () => void {
stateListeners.add(listener);
return () => {
stateListeners.delete(listener);
};
},
onAny(listener): () => void {
globalListeners.add(listener);
return () => {
globalListeners.delete(listener);
};
},
clear(): void {
stateListeners.clear();
globalListeners.clear();
}
};
}

@ -0,0 +1,110 @@
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_IDLE,
CONNECTION_STATE_OPEN
} from './consts.ts';
import type { ConnectionState, ConnectionStateChange } from './types.ts';
export interface ConnectionStateTracker {
readonly state: ConnectionState;
readonly generation: number;
readonly error: unknown | null;
readonly openedAt: number | null;
readonly closedAt: number | null;
readonly lastMessageAt: number | null;
readonly reconnectAttempt: number;
setError(error: unknown | null): void;
setReconnectAttempt(attempt: number): void;
touchMessage(): number;
isClosedLike(): boolean;
transition(to: ConnectionState, error?: unknown): boolean;
markOpen(): boolean;
markClosed(to: ConnectionState, error?: unknown, beforeEmit?: () => void): boolean;
}
export function createConnectionStateTracker(input: {
readonly name: string;
readonly now: () => number;
readonly emitState: (change: ConnectionStateChange) => void;
}): ConnectionStateTracker {
let state: ConnectionState = CONNECTION_STATE_IDLE;
let generation = 0;
let error: unknown | null = null;
let openedAt: number | null = null;
let closedAt: number | null = null;
let lastMessageAt: number | null = null;
let reconnectAttempt = 0;
function transition(to: ConnectionState, changeError?: unknown): boolean {
if (state === to && changeError === undefined) return false;
const from = state;
state = to;
input.emitState({
connection: input.name,
from,
to,
generation,
error: changeError,
at: input.now()
});
return true;
}
return {
get state() {
return state;
},
get generation() {
return generation;
},
get error() {
return error;
},
get openedAt() {
return openedAt;
},
get closedAt() {
return closedAt;
},
get lastMessageAt() {
return lastMessageAt;
},
get reconnectAttempt() {
return reconnectAttempt;
},
setError(nextError) {
error = nextError;
},
setReconnectAttempt(attempt) {
reconnectAttempt = attempt;
},
touchMessage() {
lastMessageAt = input.now();
return lastMessageAt;
},
isClosedLike() {
return (
state === CONNECTION_STATE_CLOSED ||
state === CONNECTION_STATE_FAILED ||
state === CONNECTION_STATE_IDLE
);
},
transition,
markOpen() {
if (state === CONNECTION_STATE_OPEN) return false;
generation += 1;
error = null;
openedAt = input.now();
closedAt = null;
reconnectAttempt = 0;
return transition(CONNECTION_STATE_OPEN);
},
markClosed(to, closeError, beforeEmit) {
if (state === to) return false;
closedAt = input.now();
beforeEmit?.();
return transition(to, closeError);
}
};
}

@ -0,0 +1,28 @@
import type { TimerScheduler } from '$timr';
import { timerKey } from './helpers.ts';
export interface ConnectionTimerControls {
cancel(kind: string, id?: string): void;
schedule(kind: string, delayMs: number, task: () => void | Promise<void>, id?: string): void;
interval(kind: string, everyMs: number, task: () => void, id?: string): void;
}
export function createConnectionTimerControls(
name: string,
timers: TimerScheduler
): ConnectionTimerControls {
return {
cancel(kind, id) {
timers.cancel(timerKey(name, kind, id));
},
schedule(kind, delayMs, task, id) {
timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true });
},
interval(kind, everyMs, task, id) {
timers.interval(timerKey(name, kind, id), everyMs, task, {
replace: true,
awaitTask: false
});
}
};
}

File diff suppressed because it is too large Load Diff

@ -1,6 +1,23 @@
export const LOGGER_CATEGORY = 'conn';
export const LOGGER_SCOPE_SEPARATOR = ':';
export const CONNECTION_DIAGNOSTIC_EVENTS = {
AUTH_FAILED: 'auth_failed',
BROWSER_RECONNECT: 'browser_reconnect',
CONNECT_FAILED: 'connect_failed',
FRAME_DECODE_FAILED: 'frame_decode_failed',
FRAME_ENCODE_FAILED: 'frame_encode_failed',
HEARTBEAT_TIMEOUT: 'heartbeat_timeout',
LISTENER_THREW: 'listener_threw',
REAUTH_FAILED: 'reauth_failed',
RECONNECT_EXHAUSTED: 'reconnect_exhausted',
SEND_FAILED: 'send_failed',
SESSION_EXPIRED: 'session_expired',
SESSION_REFRESHED: 'session_refreshed',
SESSION_REVOKED: 'session_revoked',
TRANSPORT_ERROR: 'transport_error'
} as const;
export const CONNECTION_STATE_IDLE = 'idle';
export const CONNECTION_STATE_CONNECTING = 'connecting';
export const CONNECTION_STATE_OPEN = 'open';
@ -188,6 +205,7 @@ export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError';
export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError';
export const ERROR_NAME_CHANNEL_ALREADY_EXISTS = 'ConnChannelAlreadyExistsError';
export const ERROR_NAME_CHANNEL_NOT_FOUND = 'ConnChannelNotFoundError';
export const ERROR_NAME_WEBSOCKET_UNAVAILABLE = 'ConnWebSocketUnavailableError';
export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed connection engine';
export const ERROR_MSG_ALREADY_EXISTS_PREFIX = 'connection already exists: ';

@ -0,0 +1,136 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics
} from '$libs/logr';
import {
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_EVENT_MESSAGE,
LOGGER_CATEGORY,
LOG_MSG_AUTH_FAILED,
LOG_MSG_BROWSER_RECONNECT,
LOG_MSG_CONNECT_FAILED,
LOG_MSG_FRAME_DECODE_FAILED,
LOG_MSG_FRAME_ENCODE_FAILED,
LOG_MSG_HEARTBEAT_TIMEOUT,
LOG_MSG_REAUTH_FAILED,
LOG_MSG_RECONNECT_EXHAUSTED,
LOG_MSG_SEND_FAILED,
LOG_MSG_SESSION_EXPIRED,
LOG_MSG_SESSION_REFRESHED,
LOG_MSG_SESSION_REVOKED,
LOG_MSG_TRANSPORT_ERROR
} from './consts.ts';
import { listenerThrewMessage } from './helpers.ts';
import type { Logger } from '$libs/logr';
export type ConnectionDiagnosticType =
(typeof CONNECTION_DIAGNOSTIC_EVENTS)[keyof typeof CONNECTION_DIAGNOSTIC_EVENTS];
export type ConnectionDiagnosticEvent = DiagnosticEvent<ConnectionDiagnosticType>;
export type ConnectionDiagnostics = Diagnostics<ConnectionDiagnosticEvent>;
const CONNECTION_DIAGNOSTIC_LOGS: DiagnosticCatalog<ConnectionDiagnosticEvent> = {
[CONNECTION_DIAGNOSTIC_EVENTS.AUTH_FAILED]: {
level: LogLevel.WARN,
message: LOG_MSG_AUTH_FAILED
},
[CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT]: {
level: LogLevel.DEBUG,
message: LOG_MSG_BROWSER_RECONNECT
},
[CONNECTION_DIAGNOSTIC_EVENTS.CONNECT_FAILED]: {
level: LogLevel.ERROR,
message: LOG_MSG_CONNECT_FAILED
},
[CONNECTION_DIAGNOSTIC_EVENTS.FRAME_DECODE_FAILED]: {
level: LogLevel.ERROR,
message: LOG_MSG_FRAME_DECODE_FAILED
},
[CONNECTION_DIAGNOSTIC_EVENTS.FRAME_ENCODE_FAILED]: {
level: LogLevel.ERROR,
message: LOG_MSG_FRAME_ENCODE_FAILED
},
[CONNECTION_DIAGNOSTIC_EVENTS.HEARTBEAT_TIMEOUT]: {
level: LogLevel.WARN,
message: LOG_MSG_HEARTBEAT_TIMEOUT
},
[CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW]: (event) => ({
level: LogLevel.ERROR,
message: listenerThrewMessage(listenerEventName(event.meta))
}),
[CONNECTION_DIAGNOSTIC_EVENTS.REAUTH_FAILED]: {
level: LogLevel.WARN,
message: LOG_MSG_REAUTH_FAILED
},
[CONNECTION_DIAGNOSTIC_EVENTS.RECONNECT_EXHAUSTED]: {
level: LogLevel.WARN,
message: LOG_MSG_RECONNECT_EXHAUSTED
},
[CONNECTION_DIAGNOSTIC_EVENTS.SEND_FAILED]: {
level: LogLevel.ERROR,
message: LOG_MSG_SEND_FAILED
},
[CONNECTION_DIAGNOSTIC_EVENTS.SESSION_EXPIRED]: {
level: LogLevel.WARN,
message: LOG_MSG_SESSION_EXPIRED
},
[CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REFRESHED]: {
level: LogLevel.DEBUG,
message: LOG_MSG_SESSION_REFRESHED
},
[CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REVOKED]: {
level: LogLevel.WARN,
message: LOG_MSG_SESSION_REVOKED
},
[CONNECTION_DIAGNOSTIC_EVENTS.TRANSPORT_ERROR]: {
level: LogLevel.ERROR,
message: LOG_MSG_TRANSPORT_ERROR
}
};
function listenerEventName(meta: unknown): string {
if (typeof meta !== 'object' || meta === null) return CONNECTION_EVENT_MESSAGE;
if (!('event' in meta)) return CONNECTION_EVENT_MESSAGE;
const event = meta.event;
return typeof event === 'string' && event.length > 0 ? event : CONNECTION_EVENT_MESSAGE;
}
export function createConnectionDiagnostics(input: {
readonly logger?: Logger;
readonly scope: string;
}): ConnectionDiagnostics {
return createCatalogDiagnostics({
logger: input.logger,
defaultCategory: input.scope,
catalog: CONNECTION_DIAGNOSTIC_LOGS
});
}
export function emitConnectionDiagnostic(
diagnostics: ConnectionDiagnostics,
type: ConnectionDiagnosticType,
meta?: unknown
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
type,
meta
});
}
export function emitScopedConnectionDiagnostic(
diagnostics: ConnectionDiagnostics,
scope: string,
type: ConnectionDiagnosticType,
meta?: unknown
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
scope,
type,
meta
});
}

@ -5,7 +5,8 @@ import {
ERROR_NAME_DISPOSED,
ERROR_NAME_INVALID_FRAME,
ERROR_NAME_INVALID_NAME,
ERROR_NAME_NOT_FOUND
ERROR_NAME_NOT_FOUND,
ERROR_NAME_WEBSOCKET_UNAVAILABLE
} from './consts.ts';
abstract class ConnEngineError extends Error {
@ -63,6 +64,10 @@ export class ConnChannelNotFoundError extends ConnEngineError {
}
}
export class ConnWebSocketUnavailableError extends ConnEngineError {
readonly name = ERROR_NAME_WEBSOCKET_UNAVAILABLE;
}
export function isConnDisposedError(value: unknown): value is ConnDisposedError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED;
}
@ -88,3 +93,11 @@ export function isConnInvalidConnectionNameError(
export function isConnInvalidFrameError(value: unknown): value is ConnInvalidFrameError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_FRAME;
}
export function isConnWebSocketUnavailableError(
value: unknown
): value is ConnWebSocketUnavailableError {
return (
value instanceof Error && (value as { name?: string }).name === ERROR_NAME_WEBSOCKET_UNAVAILABLE
);
}

@ -0,0 +1,59 @@
import {
CONNECTION_BUFFER_POLICY_BUFFER,
CONNECTION_BUFFER_POLICY_DROP,
CONNECTION_SEND_REASON_BUFFER_FULL,
DEFAULT_BUFFER_MAX_BYTES,
DEFAULT_BUFFER_MAX_MESSAGES
} from './consts.ts';
import type { ConnectionBufferOptions, ConnectionFrame, ConnectionSendResult } from './types.ts';
export interface ConnectionFrameBuffer {
readonly maxBytes: number;
readonly length: number;
canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean;
shouldDropClosedFrame(): boolean;
push(frame: ConnectionFrame): ConnectionSendResult;
shift(): ConnectionFrame | undefined;
unshift(frame: ConnectionFrame): void;
clear(): void;
}
export function createConnectionFrameBuffer(
options: ConnectionBufferOptions = {}
): ConnectionFrameBuffer {
const frames: ConnectionFrame[] = [];
return {
get maxBytes() {
return options.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES;
},
get length() {
return frames.length;
},
canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean {
if (!allowBuffer) return false;
if (frame.ack === true) return false;
return options.policy === CONNECTION_BUFFER_POLICY_BUFFER;
},
shouldDropClosedFrame(): boolean {
return options.policy === CONNECTION_BUFFER_POLICY_DROP;
},
push(frame: ConnectionFrame): ConnectionSendResult {
const maxMessages = options.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES;
if (frames.length >= maxMessages) {
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
}
frames.push(frame);
return { ok: true, id: frame.id };
},
shift() {
return frames.shift();
},
unshift(frame: ConnectionFrame): void {
frames.unshift(frame);
},
clear(): void {
frames.length = 0;
}
};
}

@ -0,0 +1,77 @@
import {
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT,
CONNECTION_FRAME_TYPE_PING,
DEFAULT_HEARTBEAT_ENABLED,
DEFAULT_HEARTBEAT_INTERVAL_MS,
DEFAULT_HEARTBEAT_TIMEOUT_MS,
TIMER_KEY_HEARTBEAT,
TIMER_KEY_HEARTBEAT_TIMEOUT
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import { createFrame } from './serializer.ts';
import type { ConnectionHeartbeatOptions, ConnectionSendResult } from './types.ts';
interface ConnectionHeartbeatRuntime {
readonly heartbeatOptions?: ConnectionHeartbeatOptions | false;
isConnected(): boolean;
sendFrame(
frame: ReturnType<typeof createFrame>,
allowBuffer: boolean
): Promise<ConnectionSendResult>;
scheduleInterval(kind: string, everyMs: number, task: () => void, id?: string): void;
scheduleTimer(kind: string, delayMs: number, task: () => void | Promise<void>, id?: string): void;
cancelTimer(kind: string, id?: string): void;
closeTransport(reason: string): void;
readonly diagnostics: ConnectionDiagnostics;
}
export interface ConnectionHeartbeat {
received(): void;
start(): void;
stop(): void;
}
export function createConnectionHeartbeat(
runtime: ConnectionHeartbeatRuntime
): ConnectionHeartbeat {
async function sendHeartbeat(): Promise<void> {
if (!runtime.isConnected()) return;
const pingType =
runtime.heartbeatOptions === false ? undefined : runtime.heartbeatOptions?.pingType;
const result = await runtime.sendFrame(
createFrame({
type: pingType ?? CONNECTION_FRAME_TYPE_PING,
payload: undefined
}),
false
);
if (!result.ok) return;
const timeoutMs =
runtime.heartbeatOptions === false
? DEFAULT_HEARTBEAT_TIMEOUT_MS
: (runtime.heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS);
runtime.scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => {
emitConnectionDiagnostic(runtime.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.HEARTBEAT_TIMEOUT);
runtime.closeTransport(CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT);
});
}
return {
received(): void {
runtime.cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
},
start(): void {
if (runtime.heartbeatOptions === false) return;
if ((runtime.heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return;
const intervalMs = runtime.heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
runtime.scheduleInterval(TIMER_KEY_HEARTBEAT, intervalMs, () => {
void sendHeartbeat();
});
},
stop(): void {
runtime.cancelTimer(TIMER_KEY_HEARTBEAT);
runtime.cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
}
};
}

@ -2,6 +2,11 @@ export { createActiveConnections } from './active-connections.svelte.ts';
export { createConnectionChannel } from './channel.ts';
export { createConnection } from './connection.ts';
export { createEngineConnections } from './engine-connections.ts';
export {
createConnectionDiagnostics,
emitConnectionDiagnostic,
emitScopedConnectionDiagnostic
} from './diagnostics.ts';
export { assertConnectionFrame, createFrame } from './serializer.ts';
export { jsonConnectionSerializer } from './serializers/json.ts';
export { createMockTransport } from './transports/mock.ts';
@ -11,3 +16,8 @@ export * from './consts.ts';
export * from './errors.ts';
export * from './helpers.ts';
export * from './types.ts';
export type {
ConnectionDiagnosticEvent,
ConnectionDiagnosticType,
ConnectionDiagnostics
} from './diagnostics.ts';

@ -0,0 +1,62 @@
import { computeBackoffDelay } from '$libs/timers';
import {
DEFAULT_RECONNECT_ENABLED,
DEFAULT_RECONNECT_FACTOR,
DEFAULT_RECONNECT_JITTER_MS,
DEFAULT_RECONNECT_MAX_DELAY_MS,
DEFAULT_RECONNECT_MIN_DELAY_MS
} from './consts.ts';
import type { ConnectionOptions, ConnectionReconnectOptions } from './types.ts';
export type ConnectionReconnectPlan =
| {
readonly ok: true;
readonly attempt: number;
readonly delayMs: number;
}
| {
readonly ok: false;
readonly reconnectAttempt: number;
readonly maxAttempts: number;
};
export interface ConnectionReconnectPolicy {
readonly disabled: boolean;
readonly options?: ConnectionReconnectOptions;
isEnabled(input: { readonly disposed: boolean; readonly intentionalClose: boolean }): boolean;
next(currentAttempt: number): ConnectionReconnectPlan;
}
export function createConnectionReconnectPolicy(
reconnect: ConnectionOptions['reconnect']
): ConnectionReconnectPolicy {
const disabled = reconnect === false;
const options = disabled ? undefined : reconnect;
return {
disabled,
options,
isEnabled(input) {
if (input.disposed || input.intentionalClose) return false;
if (disabled) return false;
return options?.enabled ?? DEFAULT_RECONNECT_ENABLED;
},
next(currentAttempt) {
const maxAttempts = options?.maxAttempts;
if (maxAttempts !== undefined && currentAttempt >= maxAttempts) {
return { ok: false, reconnectAttempt: currentAttempt, maxAttempts };
}
const attempt = currentAttempt + 1;
return {
ok: true,
attempt,
delayMs: computeBackoffDelay(attempt - 1, {
minDelayMs: options?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS,
maxDelayMs: options?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
factor: options?.factor ?? DEFAULT_RECONNECT_FACTOR,
jitterMs: options?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS
})
};
}
};
}

@ -0,0 +1,99 @@
import { isPromiseLike } from '$libs/standard-schema';
import {
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_SEND_REASON_BUFFER_FULL,
CONNECTION_SEND_REASON_CLOSED,
CONNECTION_SEND_REASON_INVALID_FRAME,
CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED,
CONNECTION_SEND_REASON_SERIALIZE_FAILED,
CONNECTION_SEND_REASON_TRANSPORT_ERROR
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionFrameBuffer } from './frame-buffer.ts';
import { createFrame } from './serializer.ts';
import type {
ConnectionFrame,
ConnectionSendResult,
ConnectionSerializer,
ConnectionTransport
} from './types.ts';
interface ConnectionSenderRuntime {
readonly serializer: ConnectionSerializer;
readonly frameBuffer: ConnectionFrameBuffer;
isConnected(): boolean;
getTransport(): ConnectionTransport | null;
setError(error: unknown): void;
readonly diagnostics: ConnectionDiagnostics;
}
export interface ConnectionSender {
sendFrame(frame: ConnectionFrame, allowBuffer: boolean): Promise<ConnectionSendResult>;
flushBuffer(): Promise<void>;
}
export function createConnectionSender(runtime: ConnectionSenderRuntime): ConnectionSender {
async function sendFrame(
frame: ConnectionFrame,
allowBuffer: boolean
): Promise<ConnectionSendResult> {
try {
createFrame(frame);
} catch (err) {
return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err };
}
if (!runtime.isConnected()) {
if (runtime.frameBuffer.canBuffer(frame, allowBuffer)) return runtime.frameBuffer.push(frame);
if (runtime.frameBuffer.shouldDropClosedFrame()) return { ok: true, id: frame.id };
return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED };
}
const currentTransport = runtime.getTransport();
if (currentTransport === null || !currentTransport.canSend) {
return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED };
}
if (currentTransport.bufferedAmount > runtime.frameBuffer.maxBytes) {
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
}
let encoded: string | ArrayBuffer;
try {
encoded = runtime.serializer.encode(frame);
} catch (err) {
emitConnectionDiagnostic(
runtime.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.FRAME_ENCODE_FAILED,
{ error: err }
);
return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err };
}
try {
const maybe = currentTransport.send(encoded);
if (isPromiseLike(maybe)) await maybe;
return { ok: true, id: frame.id };
} catch (err) {
runtime.setError(err);
emitConnectionDiagnostic(runtime.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.SEND_FAILED, {
error: err
});
return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err };
}
}
return {
sendFrame,
async flushBuffer(): Promise<void> {
while (runtime.isConnected() && runtime.frameBuffer.length > 0) {
const frame = runtime.frameBuffer.shift();
if (frame === undefined) return;
const result = await sendFrame(frame, false);
if (!result.ok) {
runtime.frameBuffer.unshift(frame);
return;
}
}
}
};
}

@ -0,0 +1,66 @@
import {
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_CLOSE_REASON_SESSION_EXPIRED,
SESSION_EVENT_EXPIRED,
SESSION_EVENT_REFRESHED,
SESSION_EVENT_REVOKED
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type {
ConnectionAuthResult,
ConnectionSessionOptions,
ConnectionSessionSource
} from './types.ts';
interface ConnectionSessionWiringOptions {
readonly sessionOptions?: false | ConnectionSessionOptions;
readonly source?: ConnectionSessionSource;
readonly reauthenticate: () => Promise<ConnectionAuthResult>;
readonly disconnect: (reason: string) => void;
readonly diagnostics: ConnectionDiagnostics;
}
export function wireConnectionSession(options: ConnectionSessionWiringOptions): () => void {
const sessionOptions = options.sessionOptions;
if (
sessionOptions === undefined ||
sessionOptions === false ||
sessionOptions.enabled === false
) {
return () => {};
}
if (options.source === undefined) return () => {};
return options.source.onChange((change) => {
if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) {
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REFRESHED,
{ event: change.event }
);
void options.reauthenticate().then((result) => {
if (!result.ok) {
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.REAUTH_FAILED,
result
);
}
});
return;
}
if (
(change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) &&
sessionOptions.disconnectOnExpire !== false
) {
emitConnectionDiagnostic(
options.diagnostics,
change.event === SESSION_EVENT_EXPIRED
? CONNECTION_DIAGNOSTIC_EVENTS.SESSION_EXPIRED
: CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REVOKED,
{ event: change.event }
);
options.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED);
}
});
}

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_IDLE,
CONNECTION_STATE_OPEN,
createMockTransport
} from '../index.ts';
import { createActiveConnections } from '../active-connections.svelte.ts';
describe('createActiveConnections', () => {
it('tracks active names and connection state buckets', async () => {
const Connections = createActiveConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
Connections.createConnection('chat', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
expect(Connections.size).toBe(2);
expect(Connections.activeNames).toEqual(['main', 'chat']);
expect(Connections.states).toEqual({
main: CONNECTION_STATE_IDLE,
chat: CONNECTION_STATE_IDLE
});
expect(Connections.allConnected).toBe(false);
expect(Connections.anyConnected).toBe(false);
await Main.connect();
expect(Connections.connectedNames).toEqual(['main']);
expect(Connections.anyConnected).toBe(true);
expect(Connections.allConnected).toBe(false);
Main.disconnect();
expect(Connections.closedNames).toEqual(['main']);
expect(Connections.states.main).toBe(CONNECTION_STATE_CLOSED);
Connections.dispose();
expect(Connections.size).toBe(0);
expect(Connections.activeNames).toEqual([]);
expect(Connections.states).toEqual({});
});
it('keeps active state updated when operations go through the active root', async () => {
const Connections = createActiveConnections();
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
await Connections.openConnection('main');
expect(Connections.states.main).toBe(CONNECTION_STATE_OPEN);
expect(Connections.allConnected).toBe(true);
Connections.closeConnection('main');
expect(Connections.states.main).toBe(CONNECTION_STATE_CLOSED);
Connections.dispose();
});
});

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING
} from '../consts.ts';
import { createConnectionStateTracker } from '../connection-state.ts';
import type { ConnectionStateChange } from '../types.ts';
describe('ConnectionStateTracker', () => {
it('tracks open/close timestamps and emits state changes', () => {
let now = 10;
const changes: ConnectionStateChange[] = [];
const tracker = createConnectionStateTracker({
name: 'main',
now: () => now,
emitState: (change) => {
changes.push(change);
}
});
expect(tracker.markOpen()).toBe(true);
expect(tracker.state).toBe(CONNECTION_STATE_OPEN);
expect(tracker.generation).toBe(1);
expect(tracker.openedAt).toBe(10);
now = 20;
expect(tracker.markClosed(CONNECTION_STATE_CLOSED)).toBe(true);
expect(tracker.state).toBe(CONNECTION_STATE_CLOSED);
expect(tracker.closedAt).toBe(20);
expect(changes.map((change) => change.to)).toEqual([
CONNECTION_STATE_OPEN,
CONNECTION_STATE_CLOSED
]);
});
it('keeps duplicate close notifications idempotent', () => {
let now = 100;
const tracker = createConnectionStateTracker({
name: 'main',
now: () => now,
emitState: () => {}
});
tracker.markOpen();
tracker.markClosed(CONNECTION_STATE_CLOSED);
now = 200;
expect(tracker.markClosed(CONNECTION_STATE_CLOSED)).toBe(false);
expect(tracker.closedAt).toBe(100);
});
it('runs cleanup before emitting close transitions', () => {
const order: string[] = [];
const tracker = createConnectionStateTracker({
name: 'main',
now: () => 1,
emitState: () => {
order.push('emit');
}
});
tracker.markOpen();
order.length = 0;
tracker.markClosed(CONNECTION_STATE_RECONNECTING, undefined, () => {
order.push('cleanup');
});
expect(order).toEqual(['cleanup', 'emit']);
});
});

@ -78,6 +78,32 @@ describe('Connection lifecycle', () => {
Connections.dispose();
});
it('does not update closedAt when the transport repeats an intentional close', async () => {
let now = 100;
const clock: TimerClock = {
now: () => now,
setTimeout: () => 0,
clearTimeout: () => {}
};
const Connections = createEngineConnections({ clock });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
await Main.connect();
Main.disconnect();
expect(Main.closedAt).toBe(100);
now = 200;
transport.emitClose({ clean: true });
expect(Main.closedAt).toBe(100);
Connections.dispose();
});
it('does not reconnect after an intentional disconnect', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();

@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ConnWebSocketUnavailableError,
createWebSocketTransport,
isConnWebSocketUnavailableError
} from '../index.ts';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('createWebSocketTransport', () => {
it('throws a typed conn error when WebSocket is unavailable', () => {
vi.stubGlobal('WebSocket', undefined);
const transport = createWebSocketTransport({ url: 'ws://example.test/socket' });
expect(() => transport.open()).toThrow(ConnWebSocketUnavailableError);
try {
transport.open();
} catch (error) {
expect(isConnWebSocketUnavailableError(error)).toBe(true);
}
});
});

@ -0,0 +1,23 @@
import type { ConnectionCloseEvent, ConnectionTransport } from './types.ts';
export interface ConnectionTransportHandlers {
onOpen(): void;
onMessage(message: string | ArrayBuffer): void;
onClose(event: ConnectionCloseEvent): void;
onError(error: unknown): void;
}
export function attachConnectionTransport(
transport: ConnectionTransport,
handlers: ConnectionTransportHandlers
): () => void {
const detachers = [
transport.onOpen(handlers.onOpen),
transport.onMessage(handlers.onMessage),
transport.onClose(handlers.onClose),
transport.onError(handlers.onError)
];
return () => {
for (const detach of detachers) detach();
};
}

@ -17,6 +17,7 @@ import {
WEBSOCKET_READY_STATE_CLOSING,
WEBSOCKET_READY_STATE_OPEN
} from '../consts.ts';
import { ConnWebSocketUnavailableError } from '../errors.ts';
import { createTransportEmitter } from '../transport.ts';
import type {
ConnectionCloseEvent,
@ -56,7 +57,8 @@ export function createWebSocketTransport(options: WebSocketTransportOptions): Co
const ctor =
options.WebSocket ??
(globalThis as { readonly WebSocket?: WebSocketConstructorLike }).WebSocket;
if (ctor === undefined) throw new Error(ERROR_MSG_WEBSOCKET_UNAVAILABLE);
if (ctor === undefined)
throw new ConnWebSocketUnavailableError(ERROR_MSG_WEBSOCKET_UNAVAILABLE);
return ctor;
}

@ -1,3 +1,4 @@
import type { Logger } from '$libs/logr';
import type { TimerClock, TimerScheduler } from '$timr';
import type {
CONNECTION_ACK_REASON_CLOSED,
@ -51,13 +52,6 @@ export type ConnectionEventMap = Record<string, unknown>;
export type ConnectionChannelMap = Record<string, ConnectionEventMap>;
export type ConnectionMap = Record<string, ConnectionChannelMap>;
export interface ConnectionLogger {
debug?(category: string, message: string, input?: unknown): void;
info?(category: string, message: string, input?: unknown): void;
warn?(category: string, message: string, input?: unknown): void;
error?(category: string, message: string, input?: unknown): void;
}
export interface ConnectionCloseEvent {
readonly code: number;
readonly reason?: string;
@ -357,7 +351,7 @@ export interface Connection<TChannels extends ConnectionChannelMap = ConnectionC
}
export interface EngineConnectionsOptions {
readonly logger?: ConnectionLogger;
readonly logger?: Logger;
readonly timers?: TimerScheduler;
readonly clock?: TimerClock;
readonly defaults?: Partial<ConnectionOptions>;

@ -79,7 +79,9 @@ function subscribeMedia(query: string, fn: (matches: boolean) => void): () => vo
export function createActiveFrontend(options: ActiveFrontendOptions = {}): ActiveFrontend {
const localeSource = options.localeSource;
const dom = options.applyDom === false ? undefined : (options.dom ?? createActiveDom());
const ownedDom =
options.applyDom === false || options.dom !== undefined ? undefined : createActiveDom();
const dom = options.applyDom === false ? undefined : (options.dom ?? ownedDom);
let currentLocale = $state(localeSource?.getLocale() ?? options.locale ?? '');
let dirOverride = $state<Direction | null>(
@ -225,6 +227,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ
unsubscribeLocale?.();
unsubscribeDarkMode();
unsubscribeReducedMotion();
ownedDom?.dispose();
listeners.clear();
}
};

@ -2,6 +2,7 @@ import { createActiveCurrency } from './curr/active-currency.svelte';
import { createActiveDates } from './dates/active-dates.svelte';
import { createActiveNumbers } from './nums/active-numbers.svelte';
import { createActiveUnits } from './unts/active-units.svelte';
import { createActiveFormatsLocaleSource } from './active-runtime.svelte';
import type { ActiveCurrency, ActiveCurrencyOptions } from './curr';
import type { ActiveDates, ActiveDatesOptions } from './dates';
import type { ActiveNumbers, ActiveNumbersOptions } from './nums';
@ -28,29 +29,20 @@ export interface ActiveFormats {
}
export function createActiveFormats(options: ActiveFormatsOptions = {}): ActiveFormats {
let currentLocale = options.localeSource?.getLocale() ?? options.locale;
const localeSource: FormatsLocaleSource = {
getLocale: () => currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? '',
onLocaleChange: (fn) => {
const unsubscribe = options.localeSource?.onLocaleChange?.((locale) => {
currentLocale = locale;
fn(locale);
});
return () => unsubscribe?.();
}
};
const localeState = createActiveFormatsLocaleSource(options);
const localeSource = localeState.source;
const numbers = createActiveNumbers({ ...options.numbers, localeSource });
const currency = createActiveCurrency({ ...options.currency, localeSource, numbers });
const units = createActiveUnits({ ...options.units, localeSource, numbers });
const dates = createActiveDates({ ...options.dates, localeSource });
function setLocale(locale: string): void {
currentLocale = locale;
numbers.setLocale(locale);
currency.setLocale(locale);
units.setLocale(locale);
dates.setLocale(locale);
function setLocale(nextLocale: string): void {
localeState.setLocale(nextLocale);
numbers.setLocale(nextLocale);
currency.setLocale(nextLocale);
units.setLocale(nextLocale);
dates.setLocale(nextLocale);
}
return {

@ -0,0 +1,98 @@
import type { FormatsLocaleSource } from './types';
export interface ActiveFormatsLocaleSourceOptions {
locale?: string;
localeSource?: FormatsLocaleSource;
}
export interface ActiveFormatsLocaleSource {
readonly source: FormatsLocaleSource;
getLocale: () => string;
setLocale: (locale: string) => void;
}
export interface ActiveFormatsRuntimeOptions {
localeSource?: FormatsLocaleSource;
getLocale: () => string;
setLocale: (locale: string) => void;
}
export interface ActiveFormatsRuntime {
read: () => void;
notifyChange: () => void;
syncLocale: (locale: string) => void;
onChange: (fn: () => void) => () => void;
onLocaleChange: (fn: (locale: string) => void) => () => void;
dispose: () => void;
}
export function createActiveFormatsLocaleSource(
options: ActiveFormatsLocaleSourceOptions
): ActiveFormatsLocaleSource {
let currentLocale = options.localeSource?.getLocale() ?? options.locale;
const source: FormatsLocaleSource = {
getLocale: () => currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? '',
onLocaleChange: (fn) => {
const unsubscribe = options.localeSource?.onLocaleChange?.((locale) => {
currentLocale = locale;
fn(locale);
});
return () => unsubscribe?.();
}
};
return {
source,
getLocale: source.getLocale,
setLocale(locale) {
currentLocale = locale;
}
};
}
export function createActiveFormatsRuntime(
options: ActiveFormatsRuntimeOptions
): ActiveFormatsRuntime {
let version = $state(0);
const changeListeners = new Set<() => void>();
const localeListeners = new Set<(locale: string) => void>();
function read(): void {
void version;
}
function notifyChange(): void {
version++;
changeListeners.forEach((fn) => fn());
}
function syncLocale(locale: string): void {
options.setLocale(locale);
notifyChange();
localeListeners.forEach((fn) => fn(options.getLocale()));
}
const unsubscribeLocale = options.localeSource?.onLocaleChange?.(syncLocale);
return {
read,
notifyChange,
syncLocale,
onChange(fn) {
changeListeners.add(fn);
return () => changeListeners.delete(fn);
},
onLocaleChange(fn) {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
},
dispose() {
unsubscribeLocale?.();
changeListeners.clear();
localeListeners.clear();
}
};
}

@ -0,0 +1,46 @@
export interface AutoStateOptions<TMode, TValue> {
initial?: TMode;
auto: TMode;
normalize?: (value: TMode) => TMode;
toManual?: (value: TMode) => TValue;
}
export interface AutoState<TMode, TValue> {
get: (resolveAuto: () => TValue) => TValue;
set: (value: TMode) => void;
clear: () => void;
isAuto: () => boolean;
}
export function createAutoState<TMode, TValue>(
options: AutoStateOptions<TMode, TValue>
): AutoState<TMode, TValue> {
let manual = resolveManual(options.initial);
function resolveManual(value: TMode | undefined): TValue | null {
if (value === undefined || Object.is(value, options.auto)) return null;
const normalized = options.normalize?.(value) ?? value;
if (Object.is(normalized, options.auto)) return null;
return options.toManual?.(normalized) ?? (normalized as TValue);
}
return {
get(resolveAuto) {
return manual ?? resolveAuto();
},
set(value) {
manual = resolveManual(value);
},
clear() {
manual = null;
},
isAuto() {
return manual === null;
}
};
}

@ -1,4 +1,4 @@
import { SvelteSet } from 'svelte/reactivity';
import { createActiveFormatsRuntime } from '../active-runtime.svelte';
import { createEngineCurrency } from './engine-currency';
import type {
ActiveCurrency,
@ -15,45 +15,35 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ
locale: localeSource?.getLocale() ?? options.locale
});
let version = $state(0);
const currencyListeners = new SvelteSet<(currency: CurrencyCode) => void>();
const localeListeners = new SvelteSet<(locale: string) => void>();
function bump(): void {
version++;
}
const currencyListeners = new Set<(currency: CurrencyCode) => void>();
function notifyCurrency(currency: CurrencyCode): void {
currencyListeners.forEach((fn) => fn(currency));
}
function notifyLocale(locale: string): void {
localeListeners.forEach((fn) => fn(locale));
}
function syncLocale(locale: string): void {
const runtime = createActiveFormatsRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale(locale) {
const before = engine.getCurrency();
engine.setLocale(locale);
const after = engine.getCurrency();
bump();
notifyLocale(engine.getLocale());
if (before !== after) notifyCurrency(after);
}
const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale);
});
return {
getLocale(): string {
void version;
runtime.read();
return engine.getLocale();
},
setLocale(locale: string): void {
syncLocale(locale);
runtime.syncLocale(locale);
},
getCurrency(): CurrencyCode {
void version;
runtime.read();
return engine.getCurrency();
},
@ -61,7 +51,7 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ
const before = engine.getCurrency();
engine.setCurrency(currency);
const after = engine.getCurrency();
bump();
runtime.notifyChange();
if (before !== after) notifyCurrency(after);
},
@ -69,32 +59,32 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ
const before = engine.getCurrency();
engine.clearCurrency();
const after = engine.getCurrency();
bump();
runtime.notifyChange();
if (before !== after) notifyCurrency(after);
},
isCurrencyAuto(): boolean {
void version;
runtime.read();
return engine.isCurrencyAuto();
},
format(value: number, options?: CurrencyFormatOptions): string {
void version;
runtime.read();
return engine.format(value, options);
},
formatAs(value: number, currency: CurrencyCode, options?: CurrencyFormatOptions): string {
void version;
runtime.read();
return engine.formatAs(value, currency, options);
},
convert(value: number, to: CurrencyCode): Promise<number | undefined> {
void version;
runtime.read();
return engine.convert(value, to);
},
convertAs(value: number, from: CurrencyCode, to: CurrencyCode): Promise<number | undefined> {
void version;
runtime.read();
return engine.convertAs(value, from, to);
},
@ -107,15 +97,11 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ
return () => currencyListeners.delete(fn);
},
onLocaleChange(fn: (locale: string) => void): () => void {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
},
onLocaleChange: runtime.onLocaleChange,
dispose(): void {
unsubscribeLocale?.();
runtime.dispose();
currencyListeners.clear();
localeListeners.clear();
}
};
}

@ -1,5 +1,12 @@
export const LOGGER_CATEGORY = 'formats.currency';
export const CURRENCY_DIAGNOSTIC_EVENTS = {
RATES_PROVIDER_MISSING: 'formats.currency.rates_provider_missing',
RATE_NOT_AVAILABLE: 'formats.currency.rate_not_available',
INVALID_RATE: 'formats.currency.invalid_rate',
RATE_FETCH_FAILED: 'formats.currency.rate_fetch_failed'
} as const;
export const AUTO_CURRENCY = 'auto';
export const DEFAULT_CURRENCY = 'USD';

@ -0,0 +1,70 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logr';
import { CURRENCY_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts';
import { CURRENCY_ERRORS } from './errors';
import type { CurrencyCode } from './types';
export type CurrencyDiagnosticType =
(typeof CURRENCY_DIAGNOSTIC_EVENTS)[keyof typeof CURRENCY_DIAGNOSTIC_EVENTS];
export interface CurrencyDiagnosticMeta {
readonly from?: CurrencyCode;
readonly to?: CurrencyCode;
readonly rate?: number;
readonly error?: unknown;
}
export type CurrencyDiagnosticEvent = DiagnosticEvent<
CurrencyDiagnosticType,
CurrencyDiagnosticMeta
>;
export type CurrencyDiagnostics = Diagnostics<CurrencyDiagnosticEvent>;
const CURRENCY_DIAGNOSTIC_LOGS: DiagnosticCatalog<CurrencyDiagnosticEvent> = {
[CURRENCY_DIAGNOSTIC_EVENTS.RATES_PROVIDER_MISSING]: {
level: LogLevel.DEBUG,
message: CURRENCY_ERRORS.RATES_PROVIDER_MISSING
},
[CURRENCY_DIAGNOSTIC_EVENTS.RATE_NOT_AVAILABLE]: (event) => ({
level: LogLevel.DEBUG,
message: CURRENCY_ERRORS.RATE_NOT_AVAILABLE(event.meta?.from ?? '', event.meta?.to ?? '')
}),
[CURRENCY_DIAGNOSTIC_EVENTS.INVALID_RATE]: (event) => ({
level: LogLevel.WARN,
message: CURRENCY_ERRORS.INVALID_RATE(
event.meta?.from ?? '',
event.meta?.to ?? '',
event.meta?.rate ?? Number.NaN
)
}),
[CURRENCY_DIAGNOSTIC_EVENTS.RATE_FETCH_FAILED]: (event) => ({
level: LogLevel.WARN,
message: CURRENCY_ERRORS.RATE_FETCH_FAILED(event.meta?.from ?? '', event.meta?.to ?? '')
})
};
export function createCurrencyDiagnostics(logger?: Logger): CurrencyDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: CURRENCY_DIAGNOSTIC_LOGS
});
}
export function emitCurrencyDiagnostic(
diagnostics: CurrencyDiagnostics,
type: CurrencyDiagnosticType,
meta: CurrencyDiagnosticMeta
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
type,
meta
});
}

@ -1,13 +1,13 @@
import {
AUTO_CURRENCY,
CURRENCY_DIAGNOSTIC_EVENTS,
DEFAULT_CURRENCY,
DEFAULT_CURRENCY_DISPLAY,
DEFAULT_CURRENCY_SIGN,
LOGGER_CATEGORY
} from './consts';
import { DEFAULT_LOCALE } from '../consts';
import { normalizeLocaleTag, resolveLocaleInput } from '../helpers';
import { CURRENCY_ERRORS } from './errors';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics';
import { normalizeCurrencyCode, normalizeCurrencyMode } from './helpers';
import { resolveCurrency } from './locale-defaults';
import type {
@ -49,39 +49,39 @@ function getCurrencyFormatter(
}
export function createEngineCurrency(options: EngineCurrencyOptions = {}): EngineCurrency {
const localeInput = resolveLocaleInput(options.locale);
let localeGetter = localeInput.getter;
let currentLocale = localeInput.value;
let currentCurrency = normalizeCurrencyMode(options.currency ?? AUTO_CURRENCY);
const localeState = createFormatsLocaleState(options.locale);
const currencyState = createAutoState<CurrencyMode, CurrencyCode>({
initial: options.currency,
auto: AUTO_CURRENCY,
normalize: normalizeCurrencyMode
});
const defaultCurrency = normalizeCurrencyCode(options.defaultCurrency ?? DEFAULT_CURRENCY);
const { numbers, rates, logger } = options;
const diagnostics = createCurrencyDiagnostics(logger);
const defaultFormat = options.format ?? {};
function getLocale(): string {
const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale);
return locale || DEFAULT_LOCALE;
return localeState.getLocale();
}
function setLocale(locale: string): void {
localeGetter = undefined;
currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE;
localeState.setLocale(locale);
}
function getCurrency(): CurrencyCode {
if (currentCurrency !== AUTO_CURRENCY) return currentCurrency;
return resolveCurrency(getLocale()) ?? defaultCurrency;
return currencyState.get(() => resolveCurrency(getLocale()) ?? defaultCurrency);
}
function setCurrency(currency: CurrencyMode): void {
currentCurrency = normalizeCurrencyMode(currency);
currencyState.set(currency);
}
function clearCurrency(): void {
currentCurrency = AUTO_CURRENCY;
currencyState.clear();
}
function isCurrencyAuto(): boolean {
return currentCurrency === AUTO_CURRENCY;
return currencyState.isAuto();
}
function mergedFormatOptions(options?: CurrencyFormatOptions): CurrencyFormatOptions {
@ -117,21 +117,19 @@ export function createEngineCurrency(options: EngineCurrencyOptions = {}): Engin
if (normalizedFrom === normalizedTo) return value;
if (rates === undefined) {
logger?.debug(LOGGER_CATEGORY, CURRENCY_ERRORS.RATES_PROVIDER_MISSING, {
context: { from: normalizedFrom, to: normalizedTo }
emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATES_PROVIDER_MISSING, {
from: normalizedFrom,
to: normalizedTo
});
return undefined;
}
const rate = await rates.getRate(normalizedFrom, normalizedTo);
if (rate === undefined) {
logger?.debug(
LOGGER_CATEGORY,
CURRENCY_ERRORS.RATE_NOT_AVAILABLE(normalizedFrom, normalizedTo),
{
context: { from: normalizedFrom, to: normalizedTo }
}
);
emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATE_NOT_AVAILABLE, {
from: normalizedFrom,
to: normalizedTo
});
return undefined;
}

@ -5,6 +5,7 @@
export {
LOGGER_CATEGORY,
AUTO_CURRENCY,
CURRENCY_DIAGNOSTIC_EVENTS,
DEFAULT_CURRENCY,
DEFAULT_CURRENCY_DISPLAY,
DEFAULT_CURRENCY_SIGN,
@ -12,6 +13,7 @@ export {
} from './consts';
export { createActiveCurrency } from './active-currency.svelte';
export { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics';
export { createEngineCurrency } from './engine-currency';
export { CURRENCY_ERRORS } from './errors';
export {
@ -24,6 +26,13 @@ export { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from './locale-currencies'
export { resolveCurrency } from './locale-defaults';
export { createRates } from './rates';
export type {
CurrencyDiagnosticEvent,
CurrencyDiagnosticMeta,
CurrencyDiagnostics,
CurrencyDiagnosticType
} from './diagnostics';
export type {
CurrencyCode,
CurrencyMode,

@ -1,5 +1,5 @@
import { LOGGER_CATEGORY, RATE_PAIR_SEPARATOR } from './consts';
import { CURRENCY_ERRORS } from './errors';
import { CURRENCY_DIAGNOSTIC_EVENTS, RATE_PAIR_SEPARATOR } from './consts';
import { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics';
import { assertValidRate, isValidRate, normalizeCurrencyCode } from './helpers';
import type { CurrencyCode, CurrencyRatesProvider, RateEntry, RatesOptions } from './types';
@ -11,6 +11,7 @@ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider {
const pendingFetches = new Map<string, Promise<number | undefined>>();
const now = opts.now ?? Date.now;
const { logger } = opts;
const diagnostics = createCurrencyDiagnostics(logger);
let cacheVersion = 0;
function pairKey(from: CurrencyCode, to: CurrencyCode): string {
@ -106,8 +107,10 @@ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider {
if (fetchVersion !== cacheVersion) return undefined;
if (!result) return undefined;
if (!isValidRate(result.rate)) {
logger?.warn(LOGGER_CATEGORY, CURRENCY_ERRORS.INVALID_RATE(from, to, result.rate), {
context: { from, to, rate: result.rate }
emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.INVALID_RATE, {
from,
to,
rate: result.rate
});
return undefined;
}
@ -117,8 +120,9 @@ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider {
.catch((error: unknown) => {
pendingFetches.delete(fetchKey);
if (fetchVersion !== cacheVersion) return undefined;
logger?.warn(LOGGER_CATEGORY, CURRENCY_ERRORS.RATE_FETCH_FAILED(from, to), {
context: { from, to },
emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATE_FETCH_FAILED, {
from,
to,
error
});
return undefined;

@ -1,4 +1,4 @@
import type { EngineLogger } from '$logr';
import type { Logger } from '$libs/logr';
import type { AUTO_CURRENCY } from './consts';
import type { FormatsLocaleInput, FormatsLocaleSource } from '../types';
import type { EngineNumbers, NumbersCurrencyFormatOptions } from '../nums';
@ -32,7 +32,7 @@ export interface RatesOptions {
expiresAt?: number;
};
fetchRate?: RateFetcher;
logger?: EngineLogger;
logger?: Logger;
now?: () => number;
}
@ -50,7 +50,7 @@ export interface EngineCurrencyOptions {
defaultCurrency?: CurrencyCode;
rates?: CurrencyRatesProvider;
numbers?: Pick<EngineNumbers, 'formatCurrency'>;
logger?: EngineLogger;
logger?: Logger;
format?: CurrencyFormatOptions;
}

@ -1,4 +1,4 @@
import { SvelteSet } from 'svelte/reactivity';
import { createActiveFormatsRuntime } from '../active-runtime.svelte';
import { createEngineDates } from './engine-dates';
import type { ActiveDates, ActiveDatesOptions } from './types';
@ -9,100 +9,79 @@ export function createActiveDates(options: ActiveDatesOptions = {}): ActiveDates
locale: localeSource?.getLocale() ?? options.locale
});
let version = $state(0);
const preferenceListeners = new SvelteSet<() => void>();
const localeListeners = new SvelteSet<(locale: string) => void>();
function notifyPreferences(): void {
version++;
preferenceListeners.forEach((fn) => fn());
}
function syncLocale(locale: string): void {
engine.setLocale(locale);
notifyPreferences();
localeListeners.forEach((fn) => fn(engine.getLocale()));
}
const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale);
const runtime = createActiveFormatsRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale: (locale) => engine.setLocale(locale)
});
return {
getLocale() {
void version;
runtime.read();
return engine.getLocale();
},
setLocale: syncLocale,
setLocale: runtime.syncLocale,
getDateOrder() {
void version;
runtime.read();
return engine.getDateOrder();
},
setDateOrder(order) {
engine.setDateOrder(order);
notifyPreferences();
runtime.notifyChange();
},
clearDateOrder() {
engine.clearDateOrder();
notifyPreferences();
runtime.notifyChange();
},
isDateOrderAuto() {
void version;
runtime.read();
return engine.isDateOrderAuto();
},
getHourCycle() {
void version;
runtime.read();
return engine.getHourCycle();
},
setHourCycle(hourCycle) {
engine.setHourCycle(hourCycle);
notifyPreferences();
runtime.notifyChange();
},
clearHourCycle() {
engine.clearHourCycle();
notifyPreferences();
runtime.notifyChange();
},
isHourCycleAuto() {
void version;
runtime.read();
return engine.isHourCycleAuto();
},
formatDate(value, options) {
void version;
runtime.read();
return engine.formatDate(value, options);
},
formatTime(value, options) {
void version;
runtime.read();
return engine.formatTime(value, options);
},
formatDateTime(value, options) {
void version;
runtime.read();
return engine.formatDateTime(value, options);
},
onPreferenceChange(fn) {
preferenceListeners.add(fn);
return () => preferenceListeners.delete(fn);
},
onPreferenceChange: runtime.onChange,
onLocaleChange(fn) {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
},
onLocaleChange: runtime.onLocaleChange,
dispose() {
unsubscribeLocale?.();
preferenceListeners.clear();
localeListeners.clear();
}
dispose: runtime.dispose
};
}

@ -1,7 +1,15 @@
import { getCachedDateFormat, resolveDateOrder, resolveHourCycle } from '$libs/days';
import { DEFAULT_LOCALE } from '../consts';
import { normalizeLocaleTag, resolveLocaleInput } from '../helpers';
import type { DatesFormatOptions, EngineDates, EngineDatesOptions } from './types';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import type {
DateOrder,
DateOrderMode,
DatesFormatOptions,
EngineDates,
EngineDatesOptions,
HourCycle,
HourCycleMode
} from './types';
function withHourCycle(
options: DatesFormatOptions | undefined,
@ -15,28 +23,30 @@ function withHourCycle(
}
export function createEngineDates(options: EngineDatesOptions = {}): EngineDates {
const localeInput = resolveLocaleInput(options.locale);
let localeGetter = localeInput.getter;
let currentLocale = localeInput.value;
let dateOrderOverride = options.dateOrder === 'auto' ? null : (options.dateOrder ?? null);
let hourCycleOverride = options.hourCycle === 'auto' ? null : (options.hourCycle ?? null);
const localeState = createFormatsLocaleState(options.locale);
const dateOrder = createAutoState<DateOrderMode, DateOrder>({
initial: options.dateOrder,
auto: 'auto'
});
const hourCycleState = createAutoState<HourCycleMode, HourCycle>({
initial: options.hourCycle,
auto: 'auto'
});
function getLocale(): string {
const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale);
return locale || DEFAULT_LOCALE;
return localeState.getLocale();
}
function setLocale(locale: string): void {
localeGetter = undefined;
currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE;
localeState.setLocale(locale);
}
function getDateOrder() {
return dateOrderOverride ?? resolveDateOrder(getLocale());
return dateOrder.get(() => resolveDateOrder(getLocale()));
}
function getHourCycle() {
return hourCycleOverride ?? resolveHourCycle(getLocale());
return hourCycleState.get(() => resolveHourCycle(getLocale()));
}
function format(value: Date | number, options: DatesFormatOptions): string {
@ -49,29 +59,29 @@ export function createEngineDates(options: EngineDatesOptions = {}): EngineDates
getDateOrder,
setDateOrder(order) {
dateOrderOverride = order === 'auto' ? null : order;
dateOrder.set(order);
},
clearDateOrder() {
dateOrderOverride = null;
dateOrder.clear();
},
isDateOrderAuto() {
return dateOrderOverride === null;
return dateOrder.isAuto();
},
getHourCycle,
setHourCycle(hourCycle) {
hourCycleOverride = hourCycle === 'auto' ? null : hourCycle;
hourCycleState.set(hourCycle);
},
clearHourCycle() {
hourCycleOverride = null;
hourCycleState.clear();
},
isHourCycleAuto() {
return hourCycleOverride === null;
return hourCycleState.isAuto();
},
formatDate(value, options) {

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { createActiveDates } from '../active-dates.svelte';
describe('createActiveDates()', () => {
it('syncs locale source changes and notifies active listeners', () => {
let locale = 'en-US';
let listener: ((locale: string) => void) | undefined;
const source = {
getLocale: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) {
listener = fn;
return () => {
listener = undefined;
};
}
};
const dates = createActiveDates({ localeSource: source });
const localeChanges: string[] = [];
let preferenceChanges = 0;
dates.onLocaleChange((nextLocale) => localeChanges.push(nextLocale));
dates.onPreferenceChange(() => {
preferenceChanges++;
});
locale = 'en-GB';
listener?.(locale);
expect(dates.getLocale()).toBe('en-GB');
expect(dates.getDateOrder()).toBe('DMY');
expect(dates.getHourCycle()).toBe(24);
expect(localeChanges).toEqual(['en-GB']);
expect(preferenceChanges).toBe(1);
dates.setHourCycle(12);
expect(dates.getHourCycle()).toBe(12);
expect(preferenceChanges).toBe(2);
dates.dispose();
expect(listener).toBeUndefined();
});
});

@ -2,7 +2,7 @@ import { createEngineCurrency } from './curr';
import { createEngineDates } from './dates';
import { createEngineNumbers } from './nums';
import { createEngineUnits } from './unts';
import { DEFAULT_LOCALE } from './consts';
import { createFormatsLocaleState } from './locale-state';
import type { EngineCurrency, EngineCurrencyOptions } from './curr';
import type { EngineDates, EngineDatesOptions } from './dates';
import type { EngineNumbers, EngineNumbersOptions } from './nums';
@ -32,14 +32,8 @@ export interface EngineFormats {
}
export function createEngineFormats(options: EngineFormatsOptions = {}): EngineFormats {
let currentLocale = typeof options.locale === 'string' ? options.locale : undefined;
// `Intl.NumberFormat`/`Intl.DateTimeFormat` accept `''` but the runtime
// then falls back to the host locale silently — DEFAULT_LOCALE makes the
// behavior explicit and predictable across environments.
const locale = (): string =>
currentLocale ??
(typeof options.locale === 'function' ? options.locale() : options.locale) ??
DEFAULT_LOCALE;
const localeState = createFormatsLocaleState(options.locale);
const locale = localeState.getLocale;
const numbers = createEngineNumbers({ ...options.numbers, locale });
const currency = createEngineCurrency({ ...options.currency, locale, numbers });
@ -51,7 +45,7 @@ export function createEngineFormats(options: EngineFormatsOptions = {}): EngineF
}
function setLocale(nextLocale: string): void {
currentLocale = nextLocale;
localeState.setLocale(nextLocale);
numbers.setLocale(nextLocale);
currency.setLocale(nextLocale);
units.setLocale(nextLocale);

@ -0,0 +1,26 @@
import { DEFAULT_LOCALE } from './consts';
import { normalizeLocaleTag, resolveLocaleInput } from './helpers';
import type { FormatsLocaleInput } from './types';
export interface FormatsLocaleState {
getLocale: () => string;
setLocale: (locale: string) => void;
}
export function createFormatsLocaleState(input?: FormatsLocaleInput): FormatsLocaleState {
const localeInput = resolveLocaleInput(input);
let localeGetter = localeInput.getter;
let currentLocale = localeInput.value;
return {
getLocale() {
const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale);
return locale || DEFAULT_LOCALE;
},
setLocale(locale) {
localeGetter = undefined;
currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE;
}
};
}

@ -1,4 +1,4 @@
import { SvelteSet } from 'svelte/reactivity';
import { createActiveFormatsRuntime } from '../active-runtime.svelte';
import { createEngineNumbers } from './engine-numbers';
import type { ActiveNumbers, ActiveNumbersOptions, NumbersFormatOptions } from './types';
@ -9,135 +9,114 @@ export function createActiveNumbers(options: ActiveNumbersOptions = {}): ActiveN
locale: localeSource?.getLocale() ?? options.locale
});
let version = $state(0);
const preferenceListeners = new SvelteSet<() => void>();
const localeListeners = new SvelteSet<(locale: string) => void>();
function notifyPreferences(): void {
version++;
preferenceListeners.forEach((fn) => fn());
}
function syncLocale(locale: string): void {
engine.setLocale(locale);
notifyPreferences();
localeListeners.forEach((fn) => fn(engine.getLocale()));
}
const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale);
const runtime = createActiveFormatsRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale: (locale) => engine.setLocale(locale)
});
return {
getLocale() {
void version;
runtime.read();
return engine.getLocale();
},
setLocale: syncLocale,
setLocale: runtime.syncLocale,
format(value: number, options?: NumbersFormatOptions): string {
void version;
runtime.read();
return engine.format(value, options);
},
formatPercent(value, options) {
void version;
runtime.read();
return engine.formatPercent(value, options);
},
formatCompact(value, options) {
void version;
runtime.read();
return engine.formatCompact(value, options);
},
formatCurrency(value, currency, options) {
void version;
runtime.read();
return engine.formatCurrency(value, currency, options);
},
formatUnit(value, unit, options) {
void version;
runtime.read();
return engine.formatUnit(value, unit, options);
},
parse(value) {
void version;
runtime.read();
return engine.parse(value);
},
getDecimalSeparator() {
void version;
runtime.read();
return engine.getDecimalSeparator();
},
getGroupSeparator() {
void version;
runtime.read();
return engine.getGroupSeparator();
},
getGrouping() {
void version;
runtime.read();
return engine.getGrouping();
},
setDecimalSeparator(separator) {
engine.setDecimalSeparator(separator);
notifyPreferences();
runtime.notifyChange();
},
clearDecimalSeparator() {
engine.clearDecimalSeparator();
notifyPreferences();
runtime.notifyChange();
},
isDecimalSeparatorAuto() {
void version;
runtime.read();
return engine.isDecimalSeparatorAuto();
},
setGroupSeparator(separator) {
engine.setGroupSeparator(separator);
notifyPreferences();
runtime.notifyChange();
},
clearGroupSeparator() {
engine.clearGroupSeparator();
notifyPreferences();
runtime.notifyChange();
},
isGroupSeparatorAuto() {
void version;
runtime.read();
return engine.isGroupSeparatorAuto();
},
setGrouping(enabled) {
engine.setGrouping(enabled);
notifyPreferences();
runtime.notifyChange();
},
clearGrouping() {
engine.clearGrouping();
notifyPreferences();
runtime.notifyChange();
},
isGroupingAuto() {
void version;
runtime.read();
return engine.isGroupingAuto();
},
onPreferenceChange(fn) {
preferenceListeners.add(fn);
return () => preferenceListeners.delete(fn);
},
onPreferenceChange: runtime.onChange,
onLocaleChange(fn) {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
},
onLocaleChange: runtime.onLocaleChange,
dispose() {
unsubscribeLocale?.();
preferenceListeners.clear();
localeListeners.clear();
}
dispose: runtime.dispose
};
}

@ -1,88 +1,65 @@
import { AUTO_VALUE, DEFAULT_LOCALE } from '../consts';
import { normalizeLocaleTag, resolveLocaleInput } from '../helpers';
import { AUTO_VALUE } from '../consts';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import {
getCachedNumberFormat,
getNumberFormatPart,
normalizeNumberFormatOptions
} from './number-format';
import { parseLocaleNumber } from './number-parse';
import type {
EngineNumbers,
EngineNumbersOptions,
NumbersCurrencyFormatOptions,
NumbersFormatOptions,
NumbersGroupingMode,
NumbersSeparatorMode,
NumbersUnitFormatOptions
} from './types';
const formatCache = new Map<string, Intl.NumberFormat>();
function normalizeOptions(options: NumbersFormatOptions = {}): Intl.NumberFormatOptions {
const { minDecimals, maxDecimals, ...rest } = options;
return {
...rest,
minimumFractionDigits: minDecimals ?? rest.minimumFractionDigits,
maximumFractionDigits: maxDecimals ?? rest.maximumFractionDigits
};
}
function getCachedFormat(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat {
const key = JSON.stringify([locale, options ?? {}]);
const cached = formatCache.get(key);
if (cached !== undefined) return cached;
const formatter = new Intl.NumberFormat(locale || undefined, options);
formatCache.set(key, formatter);
return formatter;
}
function getPart(locale: string, value: number, type: Intl.NumberFormatPartTypes): string {
const parts = getCachedFormat(locale).formatToParts(value);
return parts.find((part) => part.type === type)?.value ?? '';
}
function buildNumeralMap(locale: string): Map<string, string> {
const numerals = getCachedFormat(locale, { useGrouping: false }).format(9876543210);
const map = new Map<string, string>();
for (const [index, char] of [...numerals].entries()) {
map.set(char, String(9 - index));
}
return map;
}
export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineNumbers {
const localeInput = resolveLocaleInput(options.locale);
let localeGetter = localeInput.getter;
let currentLocale = localeInput.value;
let decimalSeparator =
options.decimalSeparator === AUTO_VALUE ? null : (options.decimalSeparator ?? null);
let groupSeparator =
options.groupSeparator === AUTO_VALUE ? null : (options.groupSeparator ?? null);
let grouping = options.grouping === AUTO_VALUE ? null : (options.grouping ?? null);
const localeState = createFormatsLocaleState(options.locale);
const decimalSeparator = createAutoState<NumbersSeparatorMode, string>({
initial: options.decimalSeparator,
auto: AUTO_VALUE
});
const groupSeparator = createAutoState<NumbersSeparatorMode, string>({
initial: options.groupSeparator,
auto: AUTO_VALUE
});
const grouping = createAutoState<NumbersGroupingMode, boolean>({
initial: options.grouping,
auto: AUTO_VALUE
});
const defaultFormat = options.format ?? {};
function getLocale(): string {
const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale);
return locale || DEFAULT_LOCALE;
return localeState.getLocale();
}
function setLocale(locale: string): void {
localeGetter = undefined;
currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE;
localeState.setLocale(locale);
}
function getDecimalSeparator(): string {
return (decimalSeparator ?? getPart(getLocale(), 1.1, 'decimal')) || '.';
return decimalSeparator.get(() => getNumberFormatPart(getLocale(), 1.1, 'decimal') || '.');
}
function getGroupSeparator(): string {
return (groupSeparator ?? getPart(getLocale(), 1000, 'group')) || ',';
return groupSeparator.get(() => getNumberFormatPart(getLocale(), 1000, 'group') || ',');
}
function getGrouping(): boolean {
return grouping ?? true;
return grouping.get(() => true);
}
function mergedOptions(options?: NumbersFormatOptions): Intl.NumberFormatOptions {
return normalizeOptions({ ...defaultFormat, ...options });
return normalizeNumberFormatOptions({ ...defaultFormat, ...options });
}
function format(value: number, options?: NumbersFormatOptions): string {
if (!Number.isFinite(value)) return String(value);
return getCachedFormat(getLocale(), mergedOptions(options)).format(value);
return getCachedNumberFormat(getLocale(), mergedOptions(options)).format(value);
}
return {
@ -117,25 +94,11 @@ export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineN
},
parse(value: string): number | undefined {
if (value.trim() === '') return undefined;
const locale = getLocale();
const numerals = buildNumeralMap(locale);
const decimal = getDecimalSeparator();
const group = getGroupSeparator();
let normalized = '';
for (const char of value.trim()) {
normalized += numerals.get(char) ?? char;
}
if (group !== '') normalized = normalized.replaceAll(group, '');
if (decimal !== '.') normalized = normalized.replace(decimal, '.');
normalized = normalized.replace(/[\s\u00a0\u200e\u200f]/g, '');
normalized = normalized.replace(/[^\d.+\-eE]/g, '');
const parsed = Number(normalized);
return Number.isNaN(parsed) ? undefined : parsed;
return parseLocaleNumber(value, {
locale: getLocale(),
decimalSeparator: getDecimalSeparator(),
groupSeparator: getGroupSeparator()
});
},
getDecimalSeparator,
@ -143,39 +106,39 @@ export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineN
getGrouping,
setDecimalSeparator(separator) {
decimalSeparator = separator === AUTO_VALUE ? null : separator;
decimalSeparator.set(separator);
},
clearDecimalSeparator() {
decimalSeparator = null;
decimalSeparator.clear();
},
isDecimalSeparatorAuto() {
return decimalSeparator === null;
return decimalSeparator.isAuto();
},
setGroupSeparator(separator) {
groupSeparator = separator === AUTO_VALUE ? null : separator;
groupSeparator.set(separator);
},
clearGroupSeparator() {
groupSeparator = null;
groupSeparator.clear();
},
isGroupSeparatorAuto() {
return groupSeparator === null;
return groupSeparator.isAuto();
},
setGrouping(enabled) {
grouping = enabled === AUTO_VALUE ? null : enabled;
grouping.set(enabled);
},
clearGrouping() {
grouping = null;
grouping.clear();
},
isGroupingAuto() {
return grouping === null;
return grouping.isAuto();
}
};
}

@ -0,0 +1,36 @@
import type { NumbersFormatOptions } from './types';
const formatCache = new Map<string, Intl.NumberFormat>();
export function normalizeNumberFormatOptions(
options: NumbersFormatOptions = {}
): Intl.NumberFormatOptions {
const { minDecimals, maxDecimals, ...rest } = options;
return {
...rest,
minimumFractionDigits: minDecimals ?? rest.minimumFractionDigits,
maximumFractionDigits: maxDecimals ?? rest.maximumFractionDigits
};
}
export function getCachedNumberFormat(
locale: string,
options?: Intl.NumberFormatOptions
): Intl.NumberFormat {
const key = JSON.stringify([locale, options ?? {}]);
const cached = formatCache.get(key);
if (cached !== undefined) return cached;
const formatter = new Intl.NumberFormat(locale || undefined, options);
formatCache.set(key, formatter);
return formatter;
}
export function getNumberFormatPart(
locale: string,
value: number,
type: Intl.NumberFormatPartTypes
): string {
const parts = getCachedNumberFormat(locale).formatToParts(value);
return parts.find((part) => part.type === type)?.value ?? '';
}

@ -0,0 +1,45 @@
import { getCachedNumberFormat } from './number-format';
const numeralMapCache = new Map<string, Map<string, string>>();
export interface ParseLocaleNumberOptions {
locale: string;
decimalSeparator: string;
groupSeparator: string;
}
function buildNumeralMap(locale: string): Map<string, string> {
const cached = numeralMapCache.get(locale);
if (cached !== undefined) return cached;
const numerals = getCachedNumberFormat(locale, { useGrouping: false }).format(9876543210);
const map = new Map<string, string>();
for (const [index, char] of [...numerals].entries()) {
map.set(char, String(9 - index));
}
numeralMapCache.set(locale, map);
return map;
}
export function parseLocaleNumber(
value: string,
options: ParseLocaleNumberOptions
): number | undefined {
if (value.trim() === '') return undefined;
const numerals = buildNumeralMap(options.locale);
let normalized = '';
for (const char of value.trim()) {
normalized += numerals.get(char) ?? char;
}
if (options.groupSeparator !== '') normalized = normalized.replaceAll(options.groupSeparator, '');
if (options.decimalSeparator !== '.') {
normalized = normalized.replace(options.decimalSeparator, '.');
}
normalized = normalized.replace(/[\s\u00a0\u200e\u200f]/g, '');
normalized = normalized.replace(/[^\d.+\-eE]/g, '');
const parsed = Number(normalized);
return Number.isNaN(parsed) ? undefined : parsed;
}

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { createActiveNumbers } from '../active-numbers.svelte';
describe('createActiveNumbers()', () => {
it('syncs locale source changes and notifies active listeners', () => {
let locale = 'en-US';
let listener: ((locale: string) => void) | undefined;
const source = {
getLocale: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) {
listener = fn;
return () => {
listener = undefined;
};
}
};
const nums = createActiveNumbers({ localeSource: source });
const localeChanges: string[] = [];
let preferenceChanges = 0;
nums.onLocaleChange((nextLocale) => localeChanges.push(nextLocale));
nums.onPreferenceChange(() => {
preferenceChanges++;
});
locale = 'de-DE';
listener?.(locale);
expect(nums.getLocale()).toBe('de-DE');
expect(nums.getDecimalSeparator()).toBe(',');
expect(localeChanges).toEqual(['de-DE']);
expect(preferenceChanges).toBe(1);
nums.setGrouping(false);
expect(nums.getGrouping()).toBe(false);
expect(preferenceChanges).toBe(2);
nums.dispose();
expect(listener).toBeUndefined();
});
});

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { createAutoState } from '../auto-state';
describe('createAutoState()', () => {
it('switches between resolved auto values and manual values', () => {
const state = createAutoState<'auto' | 'manual', string>({
initial: 'auto',
auto: 'auto',
toManual: (value) => value
});
expect(state.isAuto()).toBe(true);
expect(state.get(() => 'resolved')).toBe('resolved');
state.set('manual');
expect(state.isAuto()).toBe(false);
expect(state.get(() => 'resolved')).toBe('manual');
state.clear();
expect(state.isAuto()).toBe(true);
expect(state.get(() => 'resolved')).toBe('resolved');
});
it('normalizes manual values before storing them', () => {
const state = createAutoState<string, string>({
initial: 'eur',
auto: 'auto',
normalize: (value) => value.toLowerCase(),
toManual: (value) => value.toUpperCase()
});
expect(state.get(() => 'USD')).toBe('EUR');
state.set('AUTO');
expect(state.isAuto()).toBe(true);
expect(state.get(() => 'USD')).toBe('USD');
});
});

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { createEngineFormats } from '..';
import { createActiveFormats, createEngineFormats } from '..';
describe('createEngineFormats()', () => {
it('composes number, currency, units and dates format engines', async () => {
@ -26,3 +26,23 @@ describe('createEngineFormats()', () => {
await expect(formats.currency.convert(1, 'EUR')).resolves.toBe(1);
});
});
describe('createActiveFormats()', () => {
it('fans out locale changes to every active format module', () => {
const formats = createActiveFormats({ locale: 'en-US' });
expect(formats.getLocale()).toBe('en-US');
expect(formats.currency.getCurrency()).toBe('USD');
expect(formats.units.getSystem()).toBe('imperial');
expect(formats.dates.getHourCycle()).toBe(12);
formats.setLocale('es-ES');
expect(formats.getLocale()).toBe('es-ES');
expect(formats.currency.getCurrency()).toBe('EUR');
expect(formats.units.getSystem()).toBe('metric');
expect(formats.dates.getDateOrder()).toBe('DMY');
formats.dispose();
});
});

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE } from '../consts';
import { createFormatsLocaleState } from '../locale-state';
describe('createFormatsLocaleState()', () => {
it('normalizes locale strings and falls back to the default locale', () => {
const state = createFormatsLocaleState('es_ES');
expect(state.getLocale()).toBe('es-ES');
state.setLocale('');
expect(state.getLocale()).toBe(DEFAULT_LOCALE);
});
it('tracks locale functions until a manual locale is set', () => {
let locale = 'en_GB';
const state = createFormatsLocaleState(() => locale);
expect(state.getLocale()).toBe('en-GB');
locale = 'fr_FR';
expect(state.getLocale()).toBe('fr-FR');
state.setLocale('de_DE');
locale = 'it_IT';
expect(state.getLocale()).toBe('de-DE');
});
});

@ -1,4 +1,4 @@
import { SvelteSet } from 'svelte/reactivity';
import { createActiveFormatsRuntime } from '../active-runtime.svelte';
import { createEngineUnits } from './engine-units';
import type { ActiveUnits, ActiveUnitsOptions, UnitId, UnitKind, UnitSystemMode } from './types';
@ -9,88 +9,67 @@ export function createActiveUnits(options: ActiveUnitsOptions = {}): ActiveUnits
locale: localeSource?.getLocale() ?? options.locale
});
let version = $state(0);
const preferenceListeners = new SvelteSet<() => void>();
const localeListeners = new SvelteSet<(locale: string) => void>();
function notifyPreferences(): void {
version++;
preferenceListeners.forEach((fn) => fn());
}
function syncLocale(locale: string): void {
engine.setLocale(locale);
notifyPreferences();
localeListeners.forEach((fn) => fn(engine.getLocale()));
}
const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale);
const runtime = createActiveFormatsRuntime({
localeSource,
getLocale: () => engine.getLocale(),
setLocale: (locale) => engine.setLocale(locale)
});
return {
getLocale() {
void version;
runtime.read();
return engine.getLocale();
},
setLocale: syncLocale,
setLocale: runtime.syncLocale,
getSystem() {
void version;
runtime.read();
return engine.getSystem();
},
setSystem(system: UnitSystemMode) {
engine.setSystem(system);
notifyPreferences();
runtime.notifyChange();
},
clearSystem() {
engine.clearSystem();
notifyPreferences();
runtime.notifyChange();
},
isSystemAuto() {
void version;
runtime.read();
return engine.isSystemAuto();
},
getDefaultUnit(kind: UnitKind, system) {
void version;
runtime.read();
return engine.getDefaultUnit(kind, system);
},
isDefaultUnit(unit: UnitId, kind, system) {
void version;
runtime.read();
return engine.isDefaultUnit(unit, kind, system);
},
format(value, unit, options) {
void version;
runtime.read();
return engine.format(value, unit, options);
},
formatDefault(value, kind, options) {
void version;
runtime.read();
return engine.formatDefault(value, kind, options);
},
convert: engine.convert,
convertToDefault: engine.convertToDefault,
onPreferenceChange(fn) {
preferenceListeners.add(fn);
return () => preferenceListeners.delete(fn);
},
onPreferenceChange: runtime.onChange,
onLocaleChange(fn) {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
},
onLocaleChange: runtime.onLocaleChange,
dispose() {
unsubscribeLocale?.();
preferenceListeners.clear();
localeListeners.clear();
}
dispose: runtime.dispose
};
}

@ -1,4 +1,4 @@
import { UNITS_ERRORS } from './errors';
import { UnitsIncompatibleUnitsError, UnitsUnknownUnitError } from './errors';
import { UNIT_DEFINITIONS } from './unit-definitions';
import type { UnitId } from './types';
@ -6,14 +6,14 @@ function temperatureToCelsius(value: number, unit: UnitId): number {
if (unit === 'celsius') return value;
if (unit === 'fahrenheit') return ((value - 32) * 5) / 9;
if (unit === 'kelvin') return value - 273.15;
throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(unit));
throw new UnitsUnknownUnitError(unit);
}
function celsiusToTemperature(value: number, unit: UnitId): number {
if (unit === 'celsius') return value;
if (unit === 'fahrenheit') return (value * 9) / 5 + 32;
if (unit === 'kelvin') return value + 273.15;
throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(unit));
throw new UnitsUnknownUnitError(unit);
}
export function convert(value: number, from: UnitId, to: UnitId): number {
@ -22,11 +22,11 @@ export function convert(value: number, from: UnitId, to: UnitId): number {
const fromDef = UNIT_DEFINITIONS[from];
const toDef = UNIT_DEFINITIONS[to];
if (fromDef === undefined) throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(from));
if (toDef === undefined) throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(to));
if (fromDef === undefined) throw new UnitsUnknownUnitError(from);
if (toDef === undefined) throw new UnitsUnknownUnitError(to);
if (fromDef.base !== toDef.base) {
throw new Error(UNITS_ERRORS.INCOMPATIBLE_UNITS(from, fromDef.base, to, toDef.base));
throw new UnitsIncompatibleUnitsError(from, fromDef.base, to, toDef.base);
}
if (fromDef.base === 'temperature') {

@ -1,4 +1,4 @@
import { UNITS_ERRORS } from './errors';
import { UnitsDefaultUnitNotFoundError } from './errors';
import { UNIT_DEFINITIONS } from './unit-definitions';
import type { UnitId, UnitKind, UnitSystem } from './types';
@ -7,7 +7,7 @@ export function resolveDefaultUnit(kind: UnitKind, system: UnitSystem): UnitId {
if (definition.defaultFor?.[system]?.includes(kind)) return unit;
}
throw new Error(UNITS_ERRORS.DEFAULT_UNIT_NOT_FOUND(system, kind));
throw new UnitsDefaultUnitNotFoundError(system, kind);
}
export function isDefaultUnit(unit: UnitId, kind: UnitKind, system: UnitSystem): boolean {

@ -1,44 +1,50 @@
import { AUTO_UNIT_SYSTEM } from './consts';
import { convert } from './conversions';
import { isDefaultUnit as isDefaultUnitFor, resolveDefaultUnit } from './defaults';
import { UNITS_ERRORS } from './errors';
import { UnitsUnknownUnitError } from './errors';
import { resolveUnitSystem } from './locale-defaults';
import { UNIT_DEFINITIONS } from './unit-definitions';
import { DEFAULT_LOCALE } from '../consts';
import { normalizeLocaleTag, resolveLocaleInput } from '../helpers';
import type { EngineUnits, EngineUnitsOptions, UnitId, UnitKind, UnitSystem } from './types';
import { createAutoState } from '../auto-state';
import { createFormatsLocaleState } from '../locale-state';
import type {
EngineUnits,
EngineUnitsOptions,
UnitId,
UnitKind,
UnitSystem,
UnitSystemMode
} from './types';
export function createEngineUnits(options: EngineUnitsOptions = {}): EngineUnits {
const localeInput = resolveLocaleInput(options.locale);
let localeGetter = localeInput.getter;
let currentLocale = localeInput.value;
let currentSystem = options.system ?? AUTO_UNIT_SYSTEM;
const localeState = createFormatsLocaleState(options.locale);
const unitSystem = createAutoState<UnitSystemMode, UnitSystem>({
initial: options.system,
auto: AUTO_UNIT_SYSTEM
});
const { numbers } = options;
function getLocale(): string {
const locale = normalizeLocaleTag(localeGetter?.() ?? currentLocale);
return locale || DEFAULT_LOCALE;
return localeState.getLocale();
}
function setLocale(locale: string): void {
localeGetter = undefined;
currentLocale = normalizeLocaleTag(locale) || DEFAULT_LOCALE;
localeState.setLocale(locale);
}
function getSystem(): UnitSystem {
return currentSystem === AUTO_UNIT_SYSTEM ? resolveUnitSystem(getLocale()) : currentSystem;
return unitSystem.get(() => resolveUnitSystem(getLocale()));
}
function setSystem(system: typeof currentSystem): void {
currentSystem = system;
function setSystem(system: UnitSystemMode): void {
unitSystem.set(system);
}
function clearSystem(): void {
currentSystem = AUTO_UNIT_SYSTEM;
unitSystem.clear();
}
function isSystemAuto(): boolean {
return currentSystem === AUTO_UNIT_SYSTEM;
return unitSystem.isAuto();
}
function getDefaultUnit(kind: UnitKind, system = getSystem()): UnitId {
@ -47,7 +53,7 @@ export function createEngineUnits(options: EngineUnitsOptions = {}): EngineUnits
function getUnitKind(unit: UnitId): UnitKind {
const definition = UNIT_DEFINITIONS[unit];
if (definition === undefined) throw new Error(UNITS_ERRORS.UNKNOWN_UNIT(unit));
if (definition === undefined) throw new UnitsUnknownUnitError(unit);
return definition.kind;
}

@ -7,3 +7,54 @@ export const UNITS_ERRORS = {
DEFAULT_UNIT_NOT_FOUND: (system: string, kind: string): string =>
`[formats.units] Default unit not found for "${system}:${kind}".`
} as const;
export const UNITS_ERROR_NAMES = {
UNKNOWN_UNIT: 'UnitsUnknownUnitError',
INCOMPATIBLE_UNITS: 'UnitsIncompatibleUnitsError',
DEFAULT_UNIT_NOT_FOUND: 'UnitsDefaultUnitNotFoundError'
} as const;
export class UnitsUnknownUnitError extends RangeError {
constructor(readonly unit: string) {
super(UNITS_ERRORS.UNKNOWN_UNIT(unit));
this.name = UNITS_ERROR_NAMES.UNKNOWN_UNIT;
}
}
export class UnitsIncompatibleUnitsError extends RangeError {
constructor(
readonly from: string,
readonly fromBase: string,
readonly to: string,
readonly toBase: string
) {
super(UNITS_ERRORS.INCOMPATIBLE_UNITS(from, fromBase, to, toBase));
this.name = UNITS_ERROR_NAMES.INCOMPATIBLE_UNITS;
}
}
export class UnitsDefaultUnitNotFoundError extends RangeError {
constructor(
readonly system: string,
readonly kind: string
) {
super(UNITS_ERRORS.DEFAULT_UNIT_NOT_FOUND(system, kind));
this.name = UNITS_ERROR_NAMES.DEFAULT_UNIT_NOT_FOUND;
}
}
export function isUnitsUnknownUnitError(error: unknown): error is UnitsUnknownUnitError {
return error instanceof UnitsUnknownUnitError;
}
export function isUnitsIncompatibleUnitsError(
error: unknown
): error is UnitsIncompatibleUnitsError {
return error instanceof UnitsIncompatibleUnitsError;
}
export function isUnitsDefaultUnitNotFoundError(
error: unknown
): error is UnitsDefaultUnitNotFoundError {
return error instanceof UnitsDefaultUnitNotFoundError;
}

@ -3,7 +3,16 @@ export { createActiveUnits } from './active-units.svelte';
export { createEngineUnits } from './engine-units';
export { convert } from './conversions';
export { resolveDefaultUnit, isDefaultUnit } from './defaults';
export { UNITS_ERRORS } from './errors';
export {
UNITS_ERROR_NAMES,
UNITS_ERRORS,
UnitsDefaultUnitNotFoundError,
UnitsIncompatibleUnitsError,
UnitsUnknownUnitError,
isUnitsDefaultUnitNotFoundError,
isUnitsIncompatibleUnitsError,
isUnitsUnknownUnitError
} from './errors';
export { resolveUnitSystem } from './locale-defaults';
export { UNIT_DEFINITIONS } from './unit-definitions';
export type {

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { createEngineNumbers } from '../../nums';
import { createActiveUnits } from '../active-units.svelte';
describe('createActiveUnits()', () => {
it('syncs locale source changes and notifies active listeners', () => {
let locale = 'en-US';
let listener: ((locale: string) => void) | undefined;
const source = {
getLocale: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) {
listener = fn;
return () => {
listener = undefined;
};
}
};
const unts = createActiveUnits({
localeSource: source,
numbers: createEngineNumbers({ locale: () => locale })
});
const localeChanges: string[] = [];
let preferenceChanges = 0;
unts.onLocaleChange((nextLocale) => localeChanges.push(nextLocale));
unts.onPreferenceChange(() => {
preferenceChanges++;
});
locale = 'es-ES';
listener?.(locale);
expect(unts.getLocale()).toBe('es-ES');
expect(unts.getSystem()).toBe('metric');
expect(unts.getDefaultUnit('distance')).toBe('kilometer');
expect(localeChanges).toEqual(['es-ES']);
expect(preferenceChanges).toBe(1);
unts.setSystem('imperial');
expect(unts.getSystem()).toBe('imperial');
expect(preferenceChanges).toBe(2);
unts.dispose();
expect(listener).toBeUndefined();
});
});

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { createEngineNumbers } from '../../nums';
import { createEngineUnits, resolveUnitSystem } from '..';
import {
createEngineUnits,
isUnitsIncompatibleUnitsError,
isUnitsUnknownUnitError,
resolveUnitSystem
} from '..';
describe('createEngineUnits()', () => {
it('resolves systems from explicit locale regions', () => {
@ -29,4 +34,20 @@ describe('createEngineUnits()', () => {
expect(unts.convert(0, 'celsius', 'fahrenheit')).toBe(32);
expect(unts.formatDefault(2, 'distance')).toContain('mi');
});
it('throws typed unit errors', () => {
const unts = createEngineUnits();
try {
unts.convert(1, 'mile', 'celsius');
} catch (error) {
expect(isUnitsIncompatibleUnitsError(error)).toBe(true);
}
try {
unts.convert(1, 'unknown', 'meter');
} catch (error) {
expect(isUnitsUnknownUnitError(error)).toBe(true);
}
});
});

@ -71,6 +71,15 @@ export const ERROR_MSG_TOTAL_TIMEOUT_PREFIX = 'Request exceeded total timeout';
export const ERROR_MSG_ABORT_PREFIX = 'Request aborted: ';
export const ERROR_MSG_ABORT_UNKNOWN_REASON = 'unknown reason';
export const HTTP_DIAGNOSTIC_EVENTS = {
REQUEST: 'http.request',
BODY_SCHEMA_REJECTED: 'http.body_schema_rejected',
NETWORK_ERROR: 'http.network_error',
RETRYING: 'http.retrying',
RESPONSE_SCHEMA_FAILED: 'http.response_schema_failed',
HTTP_STATUS: 'http.status'
} as const;
export function requestBodyValidationErrorMessage(method: HttpMethod, url: string): string {
return `${ERROR_PREFIX}Request body failed validation for ${method} ${url}`;
}

@ -0,0 +1,105 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logr';
import {
HTTP_DIAGNOSTIC_EVENTS,
HTTP_METHOD_GET,
LOGGER_CATEGORY,
bodySchemaRejectedLogMessage,
httpStatusLogMessage,
networkErrorLogMessage,
requestLogMessage,
responseSchemaFailedLogMessage,
retryingLogMessage
} from './consts.ts';
import type { HttpMethod } from './types.ts';
export type HttpDiagnosticType =
(typeof HTTP_DIAGNOSTIC_EVENTS)[keyof typeof HTTP_DIAGNOSTIC_EVENTS];
export interface HttpDiagnosticMeta {
readonly method: HttpMethod;
readonly url: string;
readonly attempt?: number;
readonly error?: unknown;
readonly issueCount?: number;
readonly retryDelay?: number;
readonly nextAttempt?: number;
readonly totalAttempts?: number;
readonly status?: number;
readonly statusText?: string;
}
export type HttpDiagnosticEvent = DiagnosticEvent<HttpDiagnosticType, HttpDiagnosticMeta>;
export type HttpDiagnostics = Diagnostics<HttpDiagnosticEvent>;
const HTTP_DIAGNOSTIC_LOGS: DiagnosticCatalog<HttpDiagnosticEvent> = {
[HTTP_DIAGNOSTIC_EVENTS.REQUEST]: (event) => ({
level: LogLevel.DEBUG,
message: requestLogMessage(methodOf(event), urlOf(event))
}),
[HTTP_DIAGNOSTIC_EVENTS.BODY_SCHEMA_REJECTED]: (event) => ({
level: LogLevel.ERROR,
message: bodySchemaRejectedLogMessage(methodOf(event), urlOf(event))
}),
[HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR]: (event) => ({
level: LogLevel.WARN,
message: networkErrorLogMessage(methodOf(event), urlOf(event))
}),
[HTTP_DIAGNOSTIC_EVENTS.RETRYING]: (event) => ({
level: LogLevel.WARN,
message: retryingLogMessage(
methodOf(event),
urlOf(event),
event.meta?.retryDelay ?? 0,
event.meta?.nextAttempt ?? 0,
event.meta?.totalAttempts ?? 0
)
}),
[HTTP_DIAGNOSTIC_EVENTS.RESPONSE_SCHEMA_FAILED]: (event) => ({
level: LogLevel.ERROR,
message: responseSchemaFailedLogMessage(methodOf(event), urlOf(event))
}),
[HTTP_DIAGNOSTIC_EVENTS.HTTP_STATUS]: (event) => ({
level: LogLevel.WARN,
message: httpStatusLogMessage(
methodOf(event),
urlOf(event),
event.meta?.status ?? 0,
event.meta?.statusText ?? ''
)
})
};
function methodOf(event: HttpDiagnosticEvent): HttpMethod {
return event.meta?.method ?? HTTP_METHOD_GET;
}
function urlOf(event: HttpDiagnosticEvent): string {
return event.meta?.url ?? LOGGER_CATEGORY;
}
export function createHttpDiagnostics(logger?: Logger): HttpDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: HTTP_DIAGNOSTIC_LOGS
});
}
export function emitHttpDiagnostic(
diagnostics: HttpDiagnostics,
type: HttpDiagnosticType,
meta: HttpDiagnosticMeta
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
type,
meta
});
}

@ -1,10 +1,7 @@
import type { StandardSchemaV1 } from '$libs/standard-schema';
import { isPromiseLike } from '$libs/standard-schema';
import { applyHeaders, mergeHeaders, parseBody, serializeBody } from './body.ts';
import { mergeHeaders, serializeBody } from './body.ts';
import {
DEFAULT_RETRY,
DEFAULT_TIMEOUT,
HTTP_HEADER_CONTENT_TYPE,
HTTP_METHOD_DELETE,
HTTP_METHOD_GET,
@ -13,21 +10,29 @@ import {
HTTP_METHOD_PATCH,
HTTP_METHOD_POST,
HTTP_METHOD_PUT,
HTTP_RESULT_KIND_HTTP,
HTTP_RESULT_KIND_NETWORK,
HTTP_RESULT_KIND_VALIDATION,
LOGGER_CATEGORY,
bodySchemaRejectedLogMessage,
httpStatusErrorMessage,
httpStatusLogMessage,
networkErrorLogMessage,
requestBodyValidationErrorMessage,
requestLogMessage,
responseSchemaFailedLogMessage,
retryingLogMessage
HTTP_DIAGNOSTIC_EVENTS
} from './consts.ts';
import { emitHttpDiagnostic } from './diagnostics.ts';
import {
freezeHttpDefaults,
mergeHttpOptions,
resolveHttpCallHooks,
resolveHttpCallRetry,
type ResolvedHttpDefaults
} from './engine-options.ts';
import { HttpBodyValidationError } from './errors.ts';
import {
runAfterResponse,
runBeforeError,
runBeforeRequest,
runBeforeRetry
} from './hooks.ts';
import { computeRetryDelay, delayWithSignal, shouldRetryRequest } from './retry.ts';
import { buildHttpFailure, buildOkOrValidation, runStandardValidate } from './results.ts';
import { appendSearch, normalizeSearch, resolveUrl } from './search.ts';
import {
attemptTimeoutSignal,
@ -36,23 +41,17 @@ import {
totalTimeoutSignal
} from './timeout.ts';
import type {
AfterResponseHook,
BeforeErrorHook,
BeforeRequestHook,
BeforeRetryHook,
EngineHttp,
EngineHttpOptions,
HookContext,
HookRequest,
HttpBodyRequestInit,
HttpGetInit,
HttpHooks,
HttpInit,
HttpMethod,
HttpResult,
HttpResultPromise,
Out,
RetryConfig
Out
} from './types.ts';
/**
@ -61,11 +60,11 @@ import type {
* object, and `with(...)` returns a new engine without touching the parent.
*/
export function createEngineHttp(options: EngineHttpOptions = {}): EngineHttp {
const defaults = freezeDefaults(options);
const defaults = freezeHttpDefaults(options);
const engine: EngineHttp = {
with(overrides) {
return createEngineHttp(mergeOptions(options, overrides));
return createEngineHttp(mergeHttpOptions(options, overrides));
},
get(url, init) {
return execute(defaults, HTTP_METHOD_GET, url, init);
@ -93,120 +92,20 @@ export function createEngineHttp(options: EngineHttpOptions = {}): EngineHttp {
return engine;
}
// ============================================================================
// INTERNALS
// ============================================================================
interface ResolvedDefaults {
readonly baseUrl: string | undefined;
readonly headers: EngineHttpOptions['headers'];
readonly fetch: typeof fetch;
readonly timeout: number;
readonly totalTimeout: number;
readonly retry: RetryConfig;
readonly hooks: Required<HttpHooks>;
readonly logger: EngineHttpOptions['logger'];
}
function freezeDefaults(options: EngineHttpOptions): ResolvedDefaults {
const retry = { ...DEFAULT_RETRY, ...options.retry };
return {
baseUrl: options.baseUrl,
headers: options.headers,
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
timeout: options.timeout ?? DEFAULT_TIMEOUT,
totalTimeout: options.totalTimeout ?? 0,
retry,
hooks: {
beforeRequest: options.hooks?.beforeRequest ?? [],
beforeRetry: options.hooks?.beforeRetry ?? [],
afterResponse: options.hooks?.afterResponse ?? [],
beforeError: options.hooks?.beforeError ?? []
},
logger: options.logger
};
}
function mergeOptions(parent: EngineHttpOptions, override: EngineHttpOptions): EngineHttpOptions {
return {
baseUrl: override.baseUrl ?? parent.baseUrl,
// Headers compose: when both layers set them, build a synthetic hook
// that applies parent first, override second (override wins on
// per-key conflict — `applyHeaders` uses `Headers.set`, not `append`).
headers: composeHeaderSources(parent.headers, override.headers),
fetch: override.fetch ?? parent.fetch,
timeout: override.timeout ?? parent.timeout,
totalTimeout: override.totalTimeout ?? parent.totalTimeout,
retry: { ...parent.retry, ...override.retry },
hooks: {
beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest),
beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry),
afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse),
beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError)
},
logger: override.logger ?? parent.logger
};
}
function composeHeaderSources(
parent: EngineHttpOptions['headers'],
override: EngineHttpOptions['headers']
): EngineHttpOptions['headers'] {
if (parent === undefined) return override;
if (override === undefined) return parent;
return async () => {
const target = new Headers();
await applyHeaders(target, parent);
await applyHeaders(target, override);
return target;
};
}
function concatHooks<H>(
a: ReadonlyArray<H> | undefined,
b: ReadonlyArray<H> | undefined
): ReadonlyArray<H> | undefined {
if (a === undefined) return b;
if (b === undefined) return a;
return [...a, ...b];
}
function resolveCallRetry(
defaults: ResolvedDefaults,
init: HttpInit | undefined
): RetryConfig | null {
if (init?.retry === false) return null;
if (init?.retry === undefined) return defaults.retry;
return { ...defaults.retry, ...init.retry };
}
function resolveCallHooks(
defaults: ResolvedDefaults,
init: HttpInit | undefined
): Required<HttpHooks> {
if (init?.hooks === undefined) return defaults.hooks;
return {
beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])],
beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])],
afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])],
beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])]
};
}
// ============================================================================
// EXECUTION
// ============================================================================
async function execute<S extends StandardSchemaV1 | undefined>(
defaults: ResolvedDefaults,
defaults: ResolvedHttpDefaults,
method: HttpMethod,
url: string,
init: (HttpGetInit<S> | HttpBodyRequestInit<S, StandardSchemaV1 | undefined>) | undefined
): HttpResultPromise<Out<S>> {
const { logger } = defaults;
const { diagnostics } = defaults;
const fetchImpl = init?.fetch ?? defaults.fetch;
const retry = resolveCallRetry(defaults, init);
const hooks = resolveCallHooks(defaults, init);
const retry = resolveHttpCallRetry(defaults, init);
const hooks = resolveHttpCallHooks(defaults, init);
// Optional body schema: pre-flight validate the request payload.
const bodyInput =
@ -226,12 +125,12 @@ async function execute<S extends StandardSchemaV1 | undefined>(
// Programmer error: log it before throwing so the failure is visible
// in the logger pipeline even when the caller's catch swallows the
// throw.
if (logger !== undefined) {
logger.error(LOGGER_CATEGORY, bodySchemaRejectedLogMessage(method, url), {
context: { url, issueCount: validated.issues.length },
emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.BODY_SCHEMA_REJECTED, {
method,
url,
issueCount: validated.issues.length,
error
});
}
throw error;
}
}
@ -288,11 +187,11 @@ async function execute<S extends StandardSchemaV1 | undefined>(
const earlyResponse = await runBeforeRequest(hooks.beforeRequest, ctx);
try {
if (logger !== undefined) {
logger.debug(LOGGER_CATEGORY, requestLogMessage(method, ctx.url), {
context: { attempt, url: ctx.url }
emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.REQUEST, {
method,
url: ctx.url,
attempt
});
}
response =
earlyResponse instanceof Response
@ -307,13 +206,13 @@ async function execute<S extends StandardSchemaV1 | undefined>(
} catch (err) {
response = undefined;
lastError = classifyFetchError(err, signal);
if (logger !== undefined) {
logger.warn(LOGGER_CATEGORY, networkErrorLogMessage(method, ctx.url), {
context: { attempt },
emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR, {
method,
url: ctx.url,
attempt,
error: lastError instanceof Error ? lastError : new Error(String(lastError))
});
}
}
// Decide whether to retry.
if (retry !== null) {
@ -324,13 +223,14 @@ async function execute<S extends StandardSchemaV1 | undefined>(
});
if (retryable) {
const delay = computeRetryDelay(retry, attempt, response);
if (logger !== undefined) {
logger.warn(
LOGGER_CATEGORY,
retryingLogMessage(method, fullUrl, delay, attempt + 1, retry.limit + 1),
{ context: { attempt, retryDelay: delay } }
);
}
emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.RETRYING, {
method,
url: fullUrl,
attempt,
retryDelay: delay,
nextAttempt: attempt + 1,
totalAttempts: retry.limit + 1
});
await runBeforeRetry(hooks.beforeRetry, {
...ctx,
error: lastError,
@ -372,9 +272,11 @@ async function execute<S extends StandardSchemaV1 | undefined>(
// Validation failure on a 2xx body is a contract violation between
// client and server — log at ERROR so it surfaces independently of
// the result handling path.
if (!result.ok && result.kind === HTTP_RESULT_KIND_VALIDATION && logger !== undefined) {
logger.error(LOGGER_CATEGORY, responseSchemaFailedLogMessage(method, fullUrl), {
context: { url: fullUrl, issueCount: result.issues.length }
if (!result.ok && result.kind === HTTP_RESULT_KIND_VALIDATION) {
emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.RESPONSE_SCHEMA_FAILED, {
method,
url: fullUrl,
issueCount: result.issues.length
});
}
return result;
@ -396,19 +298,12 @@ async function execute<S extends StandardSchemaV1 | undefined>(
// Log non-2xx as WARN — common enough to be expected (4xx mostly), but
// still useful in DevTools for diagnosing routing/auth/rate limits.
if (logger !== undefined) {
logger.warn(
LOGGER_CATEGORY,
httpStatusLogMessage(method, fullUrl, finalResponse.status, finalResponse.statusText),
{
context: {
emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.HTTP_STATUS, {
method,
url: fullUrl,
status: finalResponse.status,
statusText: finalResponse.statusText
}
}
);
}
});
return buildHttpFailure<S>(finalResponse, method);
}
@ -425,99 +320,6 @@ async function execute<S extends StandardSchemaV1 | undefined>(
return { ok: false, kind: HTTP_RESULT_KIND_NETWORK, error: lastError } as HttpResult<Out<S>>;
}
// ============================================================================
// HOOK RUNNERS
// ============================================================================
async function runBeforeRequest(
hooks: ReadonlyArray<BeforeRequestHook>,
ctx: HookContext
): Promise<Response | undefined> {
for (const hook of hooks) {
const result = await hook(ctx);
if (result instanceof Response) return result;
}
return undefined;
}
async function runBeforeRetry(
hooks: ReadonlyArray<BeforeRetryHook>,
ctx: HookContext & { error: unknown; retryDelay: number }
): Promise<void> {
for (const hook of hooks) {
await hook(ctx);
}
}
async function runAfterResponse(
hooks: ReadonlyArray<AfterResponseHook>,
ctx: HookContext,
response: Response
): Promise<Response> {
let current = response;
for (const hook of hooks) {
const result = await hook({ ...ctx, response: current });
if (result instanceof Response) current = result;
}
return current;
}
async function runBeforeError(
hooks: ReadonlyArray<BeforeErrorHook>,
ctx: HookContext & { response?: Response },
error: unknown
): Promise<Response | undefined> {
let current: Response | undefined = ctx.response;
for (const hook of hooks) {
const result = await hook({ ...ctx, response: current, error });
if (result instanceof Response) current = result;
}
// Only return when the hook actually swapped in a fresh response.
if (current !== undefined && current !== ctx.response) return current;
return undefined;
}
// ============================================================================
// RESULT BUILDERS
// ============================================================================
async function buildOkOrValidation<S extends StandardSchemaV1 | undefined>(
response: Response,
method: HttpMethod,
schema: S | undefined
): Promise<HttpResult<Out<S>>> {
if (schema === undefined) {
return { ok: true, value: undefined as Out<S>, response };
}
const body = await parseBody(response, method);
const validated = await runStandardValidate(schema, body);
if (validated.issues !== undefined) {
return {
ok: false,
kind: HTTP_RESULT_KIND_VALIDATION,
issues: validated.issues,
response
};
}
return { ok: true, value: validated.value as Out<S>, response };
}
async function buildHttpFailure<S extends StandardSchemaV1 | undefined>(
response: Response,
method: HttpMethod
): Promise<HttpResult<Out<S>>> {
const body = await parseBody(response, method);
return {
ok: false,
kind: HTTP_RESULT_KIND_HTTP,
status: response.status,
statusText: response.statusText,
body,
response
};
}
function classifyFetchError(err: unknown, signal: AbortSignal): unknown {
if (signal.aborted) {
const classified = classifyAbort(signal);
@ -525,11 +327,3 @@ function classifyFetchError(err: unknown, signal: AbortSignal): unknown {
}
return err;
}
function runStandardValidate<O>(
schema: StandardSchemaV1<unknown, O>,
value: unknown
): StandardSchemaV1.Result<O> | Promise<StandardSchemaV1.Result<O>> {
const result = schema['~standard'].validate(value);
return isPromiseLike(result) ? result : result;
}

@ -0,0 +1,103 @@
import { applyHeaders } from './body.ts';
import { DEFAULT_RETRY, DEFAULT_TIMEOUT } from './consts.ts';
import { createHttpDiagnostics, type HttpDiagnostics } from './diagnostics.ts';
import type { EngineHttpOptions, HttpHooks, HttpInit, RetryConfig } from './types.ts';
export interface ResolvedHttpDefaults {
readonly baseUrl: string | undefined;
readonly headers: EngineHttpOptions['headers'];
readonly fetch: typeof fetch;
readonly timeout: number;
readonly totalTimeout: number;
readonly retry: RetryConfig;
readonly hooks: Required<HttpHooks>;
readonly diagnostics: HttpDiagnostics;
}
export function freezeHttpDefaults(options: EngineHttpOptions): ResolvedHttpDefaults {
const retry = { ...DEFAULT_RETRY, ...options.retry };
return {
baseUrl: options.baseUrl,
headers: options.headers,
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
timeout: options.timeout ?? DEFAULT_TIMEOUT,
totalTimeout: options.totalTimeout ?? 0,
retry,
hooks: {
beforeRequest: options.hooks?.beforeRequest ?? [],
beforeRetry: options.hooks?.beforeRetry ?? [],
afterResponse: options.hooks?.afterResponse ?? [],
beforeError: options.hooks?.beforeError ?? []
},
diagnostics: createHttpDiagnostics(options.logger)
};
}
export function mergeHttpOptions(
parent: EngineHttpOptions,
override: EngineHttpOptions
): EngineHttpOptions {
return {
baseUrl: override.baseUrl ?? parent.baseUrl,
// Headers compose: when both layers set them, build a synthetic hook
// that applies parent first, override second (override wins on
// per-key conflict because `applyHeaders` uses `Headers.set`).
headers: composeHeaderSources(parent.headers, override.headers),
fetch: override.fetch ?? parent.fetch,
timeout: override.timeout ?? parent.timeout,
totalTimeout: override.totalTimeout ?? parent.totalTimeout,
retry: { ...parent.retry, ...override.retry },
hooks: {
beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest),
beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry),
afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse),
beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError)
},
logger: override.logger ?? parent.logger
};
}
export function resolveHttpCallRetry(
defaults: ResolvedHttpDefaults,
init: HttpInit | undefined
): RetryConfig | null {
if (init?.retry === false) return null;
if (init?.retry === undefined) return defaults.retry;
return { ...defaults.retry, ...init.retry };
}
export function resolveHttpCallHooks(
defaults: ResolvedHttpDefaults,
init: HttpInit | undefined
): Required<HttpHooks> {
if (init?.hooks === undefined) return defaults.hooks;
return {
beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])],
beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])],
afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])],
beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])]
};
}
function composeHeaderSources(
parent: EngineHttpOptions['headers'],
override: EngineHttpOptions['headers']
): EngineHttpOptions['headers'] {
if (parent === undefined) return override;
if (override === undefined) return parent;
return async () => {
const target = new Headers();
await applyHeaders(target, parent);
await applyHeaders(target, override);
return target;
};
}
function concatHooks<H>(
a: ReadonlyArray<H> | undefined,
b: ReadonlyArray<H> | undefined
): ReadonlyArray<H> | undefined {
if (a === undefined) return b;
if (b === undefined) return a;
return [...a, ...b];
}

@ -0,0 +1,55 @@
import type {
AfterResponseHook,
BeforeErrorHook,
BeforeRequestHook,
BeforeRetryHook,
HookContext
} from './types.ts';
export async function runBeforeRequest(
hooks: ReadonlyArray<BeforeRequestHook>,
ctx: HookContext
): Promise<Response | undefined> {
for (const hook of hooks) {
const result = await hook(ctx);
if (result instanceof Response) return result;
}
return undefined;
}
export async function runBeforeRetry(
hooks: ReadonlyArray<BeforeRetryHook>,
ctx: HookContext & { error: unknown; retryDelay: number }
): Promise<void> {
for (const hook of hooks) {
await hook(ctx);
}
}
export async function runAfterResponse(
hooks: ReadonlyArray<AfterResponseHook>,
ctx: HookContext,
response: Response
): Promise<Response> {
let current = response;
for (const hook of hooks) {
const result = await hook({ ...ctx, response: current });
if (result instanceof Response) current = result;
}
return current;
}
export async function runBeforeError(
hooks: ReadonlyArray<BeforeErrorHook>,
ctx: HookContext & { response?: Response },
error: unknown
): Promise<Response | undefined> {
let current: Response | undefined = ctx.response;
for (const hook of hooks) {
const result = await hook({ ...ctx, response: current, error });
if (result instanceof Response) current = result;
}
// Only return when the hook actually swapped in a fresh response.
if (current !== undefined && current !== ctx.response) return current;
return undefined;
}

@ -3,6 +3,7 @@
// timeout primitives they never reach.
export { createEngineHttp } from './engine-http.ts';
export { createHttpDiagnostics, emitHttpDiagnostic } from './diagnostics.ts';
export {
LOGGER_CATEGORY,
@ -12,7 +13,8 @@ export {
DEFAULT_RETRY,
DEFAULT_TIMEOUT,
MAX_ERROR_BODY_BYTES,
RETRY_AFTER_HEADERS
RETRY_AFTER_HEADERS,
HTTP_DIAGNOSTIC_EVENTS
} from './consts.ts';
export {
@ -40,7 +42,13 @@ export {
totalTimeoutSignal
} from './timeout.ts';
export { applyHeaders, isJSONSerializable, mergeHeaders, parseBody, serializeBody } from './body.ts';
export {
applyHeaders,
isJSONSerializable,
mergeHeaders,
parseBody,
serializeBody
} from './body.ts';
export { appendSearch, normalizeSearch, resolveUrl } from './search.ts';
@ -67,3 +75,9 @@ export type {
Out,
RetryConfig
} from './types.ts';
export type {
HttpDiagnosticEvent,
HttpDiagnosticMeta,
HttpDiagnostics,
HttpDiagnosticType
} from './diagnostics.ts';

@ -0,0 +1,53 @@
import type { StandardSchemaV1 } from '$libs/standard-schema';
import { isPromiseLike } from '$libs/standard-schema';
import { parseBody } from './body.ts';
import {
HTTP_RESULT_KIND_HTTP,
HTTP_RESULT_KIND_VALIDATION
} from './consts.ts';
import type { HttpMethod, HttpResult, Out } from './types.ts';
export async function buildOkOrValidation<S extends StandardSchemaV1 | undefined>(
response: Response,
method: HttpMethod,
schema: S | undefined
): Promise<HttpResult<Out<S>>> {
if (schema === undefined) {
return { ok: true, value: undefined as Out<S>, response };
}
const body = await parseBody(response, method);
const validated = await runStandardValidate(schema, body);
if (validated.issues !== undefined) {
return {
ok: false,
kind: HTTP_RESULT_KIND_VALIDATION,
issues: validated.issues,
response
};
}
return { ok: true, value: validated.value as Out<S>, response };
}
export async function buildHttpFailure<S extends StandardSchemaV1 | undefined>(
response: Response,
method: HttpMethod
): Promise<HttpResult<Out<S>>> {
const body = await parseBody(response, method);
return {
ok: false,
kind: HTTP_RESULT_KIND_HTTP,
status: response.status,
statusText: response.statusText,
body,
response
};
}
export function runStandardValidate<O>(
schema: StandardSchemaV1<unknown, O>,
value: unknown
): StandardSchemaV1.Result<O> | Promise<StandardSchemaV1.Result<O>> {
const result = schema['~standard'].validate(value);
return isPromiseLike(result) ? result : result;
}

@ -23,7 +23,7 @@ import type {
HttpMethod,
HttpSearchInit
} from '$libs/http';
import type { EngineLogger } from '$logr';
import type { Logger } from '$libs/logr';
import type {
HTTP_RESULT_KIND_HTTP,
HTTP_RESULT_KIND_NETWORK,
@ -257,7 +257,7 @@ export interface EngineHttpOptions {
/** Default hooks — per-call hooks are appended (defaults run first). */
hooks?: HttpHooks;
/** When set, structured request/retry/error events are emitted under category `'http'`. */
logger?: EngineLogger;
logger?: Logger;
}
/**

@ -427,7 +427,7 @@ In addition to the above (with `locale` made optional on `t` / `ts`):
| `PluralForms` | `{ other: string } & Partial<Record<PluralCategory, string>>` |
| `EngineLang<S>` | Pure engine public interface |
| `ActiveLang<S>` | Reactive wrapper public interface |
| `LangLogger` | `{ warn, error }` — custom logger interface |
| `Logger` | Shared logger contract from `$libs/logr`; `setLogger(logger)` is set-once and diagnostics flow through `LangDiagnostics` |
---

@ -3,12 +3,12 @@ import {
type ActiveLang,
createEngineLang,
type EngineLang,
type LangLogger,
type LangNode,
type LangParams,
type LangString,
type SupportedLocale
} from './index';
import type { Logger } from '$libs/logr';
export type { LangNode, LangParams, LangString, SupportedLocale, ActiveLang };
@ -90,7 +90,7 @@ function wrapEngine<S extends LangNode>(
return wrapEngine(childEngine, _locale, onLocaleChange);
},
setLogger(logger: LangLogger): void {
setLogger(logger: Logger): void {
engine.setLogger(logger);
},

@ -3,3 +3,16 @@ export const ID_PREFIX = '#?';
export const ID_FALLBACK_SEPARATOR = '|';
export const MAX_RESOLVE_DEPTH = 3;
export const LOGGER_CATEGORY = 'lang';
export const MONO_LANG_CATEGORY = 'lang.mono';
export const LANG_DIAGNOSTIC_EVENTS = {
KEY_NOT_FOUND: 'lang.key_not_found',
CIRCULAR_REFERENCE: 'lang.circular_reference',
MISSING_TRANSLATION: 'lang.missing_translation',
MISSING_TRANSLATION_RECORD: 'lang.missing_translation_record',
AMBIGUOUS_SIBLING_MATCH: 'lang.ambiguous_sibling_match',
EXTEND_LEAF_OVERWRITE: 'lang.extend_leaf_overwrite',
LOGGER_ALREADY_SET: 'lang.logger_already_set',
REFERENCE_FALLBACK_USED: 'lang.reference_fallback_used',
MONO_PATH_RETURNED: 'lang.mono_path_returned'
} as const;

@ -0,0 +1,106 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logr';
import { LANG_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts.ts';
import { LANG_ERRORS } from './errors.ts';
import type { SupportedLocale } from './types.ts';
export type LangDiagnosticType =
(typeof LANG_DIAGNOSTIC_EVENTS)[keyof typeof LANG_DIAGNOSTIC_EVENTS];
export interface LangDiagnosticMeta {
readonly path?: string;
readonly locale?: SupportedLocale;
readonly usedLocale?: SupportedLocale;
readonly siblingCount?: number;
readonly fallback?: string;
readonly kind?: 't' | 'ts';
readonly error?: unknown;
}
export type LangDiagnosticEvent = DiagnosticEvent<LangDiagnosticType, LangDiagnosticMeta>;
export type LangDiagnostics = Diagnostics<LangDiagnosticEvent>;
const LANG_DIAGNOSTIC_LOGS: DiagnosticCatalog<LangDiagnosticEvent> = {
[LANG_DIAGNOSTIC_EVENTS.KEY_NOT_FOUND]: (event) => ({
level: LogLevel.ERROR,
message: LANG_ERRORS.KEY_NOT_FOUND(event.meta?.path ?? '')
}),
[LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE]: (event) => ({
level: LogLevel.ERROR,
message: LANG_ERRORS.CIRCULAR_REFERENCE(event.meta?.path ?? '')
}),
[LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION]: (event) => ({
level: LogLevel.WARN,
message: LANG_ERRORS.MISSING_TRANSLATION(
event.meta?.path ?? '',
event.meta?.locale ?? 'es',
event.meta?.usedLocale ?? 'es'
)
}),
[LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION_RECORD]: (event) => ({
level: LogLevel.WARN,
message: LANG_ERRORS.MISSING_TRANSLATION_RECORD(
event.meta?.locale ?? 'es',
event.meta?.usedLocale ?? 'es'
)
}),
[LANG_DIAGNOSTIC_EVENTS.AMBIGUOUS_SIBLING_MATCH]: (event) => ({
level: LogLevel.WARN,
message: LANG_ERRORS.AMBIGUOUS_SIBLING_MATCH(
event.meta?.locale ?? 'es',
event.meta?.usedLocale ?? 'es',
event.meta?.siblingCount ?? 0,
event.meta?.path
)
}),
[LANG_DIAGNOSTIC_EVENTS.EXTEND_LEAF_OVERWRITE]: (event) => ({
level: LogLevel.WARN,
message: LANG_ERRORS.EXTEND_LEAF_OVERWRITE(event.meta?.path ?? '')
}),
[LANG_DIAGNOSTIC_EVENTS.LOGGER_ALREADY_SET]: {
level: LogLevel.WARN,
message: LANG_ERRORS.LOGGER_ALREADY_SET
},
[LANG_DIAGNOSTIC_EVENTS.REFERENCE_FALLBACK_USED]: (event) => ({
level: LogLevel.WARN,
message: LANG_ERRORS.REFERENCE_FALLBACK_USED(
event.meta?.path ?? '',
event.meta?.fallback ?? ''
)
}),
[LANG_DIAGNOSTIC_EVENTS.MONO_PATH_RETURNED]: (event) => ({
level: LogLevel.WARN,
message: LANG_ERRORS.MONO_PATH_RETURNED(
event.meta?.path ?? '',
event.meta?.kind ?? 't'
)
})
};
export function createLangDiagnostics(logger?: Logger): LangDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: LANG_DIAGNOSTIC_LOGS
});
}
export function emitLangDiagnostic(
diagnostics: LangDiagnostics,
type: LangDiagnosticType,
meta: LangDiagnosticMeta,
scope?: string
): void {
diagnostics.emit({
artifact: LOGGER_CATEGORY,
type,
scope,
meta
});
}

@ -6,11 +6,11 @@ import type {
LangRecord,
LangString,
EngineLang,
LangLogger,
LangNode
} from './types.ts';
import { LANG_ERRORS } from './errors.ts';
import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEPTH } from './consts.ts';
import { ID_PREFIX, LANG_DIAGNOSTIC_EVENTS, MAX_RESOLVE_DEPTH } from './consts.ts';
import { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
import { isLangRef, isLangRecord } from './guards.ts';
import {
interpolateTemplate,
@ -22,25 +22,26 @@ import {
resolveLocaleInRecord
} from './helpers.ts';
import { isDev as DEV } from '$libs/env';
import type { Logger } from '$libs/logr';
export function createEngineLang<S extends LangNode>(
schema: S,
defaultLocale: SupportedLocale = 'es',
fallbackChain?: SupportedLocale[],
initialLogger?: { logger: LangLogger; loggerSet: boolean }
initialLogger?: { logger: Logger; loggerSet: boolean }
): EngineLang<S> {
let currentSchema: LangNode = schema;
let logger: LangLogger = initialLogger?.logger ?? consoleLogger;
let diagnostics = createLangDiagnostics(initialLogger?.logger);
let loggerSet: boolean = initialLogger?.loggerSet ?? false;
const schemaListeners = new Set<() => void>();
function setLogger(external: LangLogger): void {
function setLogger(external: Logger): void {
if (loggerSet) {
if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET);
if (DEV) emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.LOGGER_ALREADY_SET, {});
return;
}
logger = external;
diagnostics = createLangDiagnostics(external);
loggerSet = true;
}
@ -86,15 +87,20 @@ export function createEngineLang<S extends LangNode>(
if (DEV && usedLocale !== undefined && usedLocale !== locale) {
if (ambiguousSiblingCount !== undefined) {
logger.warn(
LOGGER_CATEGORY,
LANG_ERRORS.AMBIGUOUS_SIBLING_MATCH(locale, usedLocale, ambiguousSiblingCount, path)
);
emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.AMBIGUOUS_SIBLING_MATCH, {
locale,
usedLocale,
siblingCount: ambiguousSiblingCount,
path
});
} else {
const msg = path
? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale)
: LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale);
logger.warn(LOGGER_CATEGORY, msg);
emitLangDiagnostic(
diagnostics,
path
? LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION
: LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION_RECORD,
{ path, locale, usedLocale }
);
}
}
@ -113,8 +119,9 @@ export function createEngineLang<S extends LangNode>(
if (value === undefined) return undefined;
if (depth > MAX_RESOLVE_DEPTH) {
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error('Circular reference in lang');
const path = String(value);
emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE, { path });
throw new Error(LANG_ERRORS.CIRCULAR_REFERENCE(path));
}
if (isLangRef(value)) {
@ -122,8 +129,8 @@ export function createEngineLang<S extends LangNode>(
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
const seen = visited ?? new Set<string>();
if (seen.has(path)) {
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path));
throw new Error('Circular reference in lang');
emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE, { path });
throw new Error(LANG_ERRORS.CIRCULAR_REFERENCE(path));
}
seen.add(path);
const resolved = resolvePath(currentSchema, path);
@ -131,9 +138,10 @@ export function createEngineLang<S extends LangNode>(
if (resolved === undefined) {
if (parsed?.fallback !== undefined) {
if (DEV)
logger.warn(
LOGGER_CATEGORY,
`${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
emitLangDiagnostic(
diagnostics,
LANG_DIAGNOSTIC_EVENTS.REFERENCE_FALLBACK_USED,
{ path, fallback: parsed.fallback }
);
return parsed.fallback;
}
@ -165,7 +173,10 @@ export function createEngineLang<S extends LangNode>(
if (pathFallback !== undefined) {
return interpolateTemplate(pathFallback, params);
}
if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath));
if (DEV)
emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.KEY_NOT_FOUND, {
path: cleanPath
});
return cleanPath;
}
@ -186,7 +197,10 @@ export function createEngineLang<S extends LangNode>(
}
function warnLeafOverwrite(path: string): void {
if (DEV) logger.warn(LOGGER_CATEGORY, LANG_ERRORS.EXTEND_LEAF_OVERWRITE(path));
if (DEV)
emitLangDiagnostic(diagnostics, LANG_DIAGNOSTIC_EVENTS.EXTEND_LEAF_OVERWRITE, {
path
});
}
function extend(namespace: string, module: LangNode): void {
@ -221,7 +235,10 @@ export function createEngineLang<S extends LangNode>(
[namespace]: module
} as S & { [K in NS]: M };
return createEngineLang(newSchema, defaultLocale, fallbackChain, { logger, loggerSet });
return createEngineLang(newSchema, defaultLocale, fallbackChain, {
logger: diagnostics.logger,
loggerSet
});
}
function onSchemaChange(fn: () => void): () => void {
@ -248,8 +265,3 @@ export function createEngineLang<S extends LangNode>(
getFallbackChain
};
}
const consoleLogger: LangLogger = {
warn: (_category: string, message: string) => DEV && console.warn(message),
error: (_category: string, message: string) => DEV && console.error(message)
};

@ -22,5 +22,19 @@ export const LANG_ERRORS = {
EXTEND_LEAF_OVERWRITE: (path: string): string =>
`[lang] extend(): leaf at "${path}" was overwritten silently. Last write wins — register namespaces uniquely or check for collisions before extending.`,
LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.'
LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.',
EXTEND_NAMESPACE_COLLIDES_WITH_LEAF: (path: string): string =>
`[lang] Cannot extend namespace "${path}" because it already contains a translation leaf.`,
LANG_NODE_TO_JSON_UNSAFE_DYNAMIC_FN:
'[lang] langNodeToJSON: dynamic LangFn cannot be serialized safely. Use p() for plurals or a static template function.',
JSON_TO_LANG_NODE_INVALID_SHAPE: '[lang] JSONToLangNode: invalid JSON shape for LangNode',
REFERENCE_FALLBACK_USED: (path: string, fallback: string): string =>
`${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${fallback}".`,
MONO_PATH_RETURNED: (path: string, kind: 't' | 'ts'): string =>
`Mono lang: ${kind}("${path}") returned the path verbatim. Configure a real lang schema or use the |fallback suffix to provide literal text.`
} as const;

@ -7,6 +7,7 @@ import type {
SupportedLocale
} from './types.ts';
import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
import { LANG_ERRORS } from './errors.ts';
import { isLangRecord, isLangString } from './guards.ts';
export function resolvePath(obj: LangNode | undefined, path: string): LangNode | undefined {
@ -149,9 +150,7 @@ export function deepMerge<T extends LangBranch, S extends LangBranch>(
if (isLangBranch(tgtVal)) {
result[key] = deepMerge(tgtVal, srcVal, nextPath, onLeafOverwrite);
} else if (tgtVal !== undefined) {
throw new TypeError(
`[lang] Cannot extend namespace "${nextPath}" because it already contains a translation leaf.`
);
throw new TypeError(LANG_ERRORS.EXTEND_NAMESPACE_COLLIDES_WITH_LEAF(nextPath));
} else {
result[key] = srcVal;
}

@ -4,7 +4,15 @@
// `createActiveMonoLang`) live in their own `.svelte.ts` files and must be
// imported from those paths directly so this barrel stays runes-free.
export { ID_PREFIX, ID_FALLBACK_SEPARATOR, MAX_RESOLVE_DEPTH, LOGGER_CATEGORY } from './consts.ts';
export {
ID_PREFIX,
ID_FALLBACK_SEPARATOR,
LANG_DIAGNOSTIC_EVENTS,
LOGGER_CATEGORY,
MAX_RESOLVE_DEPTH,
MONO_LANG_CATEGORY
} from './consts.ts';
export { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
export { isLangRef, isLangRecord, isLangString } from './guards.ts';
@ -27,9 +35,14 @@ export type {
PluralForms,
PluralConfig,
EngineLang,
ActiveLang,
LangLogger
ActiveLang
} from './types.ts';
export type {
LangDiagnosticEvent,
LangDiagnosticMeta,
LangDiagnostics,
LangDiagnosticType
} from './diagnostics.ts';
export { pluralRule } from './plural_rules.ts';
export type { PluralCategory } from './plural_rules.ts';

@ -1,5 +1,6 @@
import type { LangBranch, LangFn, LangNode, LangParams, PluralConfig } from './types.ts';
import { p, getPluralConfig } from './plural.ts';
import { LANG_ERRORS } from './errors.ts';
export type LangJSONValue = string | number | boolean | null | LangJSONObject | LangJSONValue[];
@ -60,9 +61,7 @@ function serializeTemplateFunction(fn: LangFn): LangJSONValue {
JSON.stringify(first) !== JSON.stringify(second) ||
JSON.stringify(first) !== JSON.stringify(json)
) {
throw new TypeError(
'[lang] langNodeToJSON: dynamic LangFn cannot be serialized safely. Use p() for plurals or a static template function.'
);
throw new TypeError(LANG_ERRORS.LANG_NODE_TO_JSON_UNSAFE_DYNAMIC_FN);
}
return json;
@ -104,7 +103,7 @@ export function JSONToLangNode(json: LangJSONValue): LangNode {
if (typeof json === 'string') return json as LangNode;
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
throw new TypeError('[lang] JSONToLangNode: invalid JSON shape for LangNode');
throw new TypeError(LANG_ERRORS.JSON_TO_LANG_NODE_INVALID_SHAPE);
}
if (isPluralJSON(json)) {

@ -2,14 +2,18 @@ import { SvelteSet } from 'svelte/reactivity';
import { interpolateTemplate, parseLangRef, parsePathFallback } from './helpers.ts';
import { isLangRecord, isLangRef } from './guards.ts';
import { isDev as DEV } from '$libs/env';
import { LANG_DIAGNOSTIC_EVENTS, MONO_LANG_CATEGORY } from './consts.ts';
import { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
import type {
ActiveLang,
LangLogger,
LangNode,
LangParams,
LangString,
SupportedLocale
} from './types.ts';
import type { Logger } from '$libs/logr';
export { MONO_LANG_CATEGORY } from './consts.ts';
/**
* Default initial locale for `createActiveMonoLang()`. The mono lang does not
@ -19,25 +23,6 @@ import type {
*/
export const DEFAULT_MONO_LOCALE: SupportedLocale = 'en';
/**
* Logger category used for the deduplicated DEV warnings emitted by mono lang
* when `t()` / `ts()` returns a path verbatim.
*/
export const MONO_LANG_CATEGORY = 'lang.mono';
/**
* Minimal interface a mono lang needs for its DEV warnings. Structurally
* compatible with `EngineLogger` and `LangLogger`, so callers can pass either
* without an adapter.
*/
export interface MonoLangLogger {
warn: (
category: string,
message: string,
input?: { context?: Record<string, unknown> }
) => void;
}
/**
* Single-language `ActiveLang` for monolingual apps.
*
@ -65,12 +50,13 @@ export interface MonoLangLogger {
*/
export interface MonoLangOptions {
initialLocale?: SupportedLocale;
logger?: MonoLangLogger;
logger?: Logger;
}
export function createActiveMonoLang(options: MonoLangOptions = {}): ActiveLang<LangNode> {
const initialLocale = options.initialLocale ?? DEFAULT_MONO_LOCALE;
let logger = options.logger;
let diagnostics = createLangDiagnostics(options.logger);
let loggerSet = options.logger !== undefined;
let locale = $state<SupportedLocale>(initialLocale);
const localeListeners = new SvelteSet<(l: SupportedLocale) => void>();
@ -89,13 +75,14 @@ export function createActiveMonoLang(options: MonoLangOptions = {}): ActiveLang<
}
function warnOnce(path: string, kind: 't' | 'ts'): void {
if (!DEV || logger === undefined) return;
if (!DEV || !loggerSet) return;
if (warnedPaths.has(path)) return;
warnedPaths.add(path);
logger.warn(
MONO_LANG_CATEGORY,
`Mono lang: ${kind}("${path}") returned the path verbatim. Configure a real lang schema or use the |fallback suffix to provide literal text.`,
{ context: { path, kind } }
emitLangDiagnostic(
diagnostics,
LANG_DIAGNOSTIC_EVENTS.MONO_PATH_RETURNED,
{ path, kind },
MONO_LANG_CATEGORY
);
}
@ -137,13 +124,15 @@ export function createActiveMonoLang(options: MonoLangOptions = {}): ActiveLang<
onSchemaChange: () => noop,
extend: noop,
register: <NS extends string, M extends LangNode>(_ns: NS, _module: M) =>
createActiveMonoLang({ initialLocale: locale, logger }) as unknown as ActiveLang<
LangNode & { [K in NS]: M }
>,
setLogger: (next: LangLogger) => {
createActiveMonoLang({
initialLocale: locale,
logger: loggerSet ? diagnostics.logger : undefined
}) as unknown as ActiveLang<LangNode & { [K in NS]: M }>,
setLogger: (next: Logger) => {
// Mirror the real engine's set-once contract: the first logger wins.
if (logger !== undefined) return;
logger = next;
if (loggerSet) return;
diagnostics = createLangDiagnostics(next);
loggerSet = true;
},
getDefaultLocale: () => locale,
getFallbackChain: () => undefined,

@ -29,6 +29,19 @@ import { p, getPluralConfig } from '../plural.ts';
import { createEngineLang } from '../engine-lang.ts';
import { langNodeToJSON, JSONToLangNode } from '../json.ts';
import type { LangNode, LangBranch } from '../types.ts';
import type { Logger } from '$libs/logr';
function createTestLogger(overrides: Partial<Logger> = {}): Logger {
return {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
...overrides
};
}
// ============================================================================
// GUARDS
@ -612,7 +625,7 @@ describe('createEngineLang — extend()', () => {
it('warns in DEV when a leaf is overwritten silently inside a deep merge', () => {
const lang = createEngineLang(schema, 'es');
const warn = vi.fn();
lang.setLogger({ warn, error: vi.fn() });
lang.setLogger(createTestLogger({ warn }));
// 'common.ok' already exists as a leaf; overwriting it with extend()
// is a silent change that "last write wins" — surface it.
@ -630,7 +643,7 @@ describe('createEngineLang — extend()', () => {
} as const satisfies LangNode;
const lang = createEngineLang(minimal, 'es');
const warn = vi.fn();
lang.setLogger({ warn, error: vi.fn() });
lang.setLogger(createTestLogger({ warn }));
lang.extend('banner', { es: 'Otro', en: 'Other' });
@ -641,7 +654,7 @@ describe('createEngineLang — extend()', () => {
it('does not warn when extend() adds a brand-new key', () => {
const lang = createEngineLang(schema, 'es');
const warn = vi.fn();
lang.setLogger({ warn, error: vi.fn() });
lang.setLogger(createTestLogger({ warn }));
lang.extend('shop', { product: { es: 'Producto' } });
@ -651,7 +664,7 @@ describe('createEngineLang — extend()', () => {
it('does not warn when extend() merges branches without leaf collisions', () => {
const lang = createEngineLang(schema, 'es');
const warn = vi.fn();
lang.setLogger({ warn, error: vi.fn() });
lang.setLogger(createTestLogger({ warn }));
// Adds a new sibling next to existing 'common.ok' and 'common.cancel'.
lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } });
@ -722,11 +735,11 @@ describe('createEngineLang — register()', () => {
const lang = createEngineLang(circularSchema, 'es');
const warn = vi.fn();
const error = vi.fn();
lang.setLogger({ warn, error });
lang.setLogger(createTestLogger({ warn, error }));
const child = lang.register('shop', { product: { es: 'Producto' } });
const childT = child.t as (p: string, params: undefined, locale: 'es') => string;
expect(() => childT('a', undefined, 'es')).toThrow('Circular reference in lang');
expect(() => childT('a', undefined, 'es')).toThrow(/Circular reference/);
expect(error).toHaveBeenCalled();
});
});
@ -736,7 +749,7 @@ describe('createEngineLang — setLogger()', () => {
const lang = createEngineLang(schema, 'es');
const warn = vi.fn();
const error = vi.fn();
lang.setLogger({ warn, error });
lang.setLogger(createTestLogger({ warn, error }));
expect(lang.getDefaultLocale()).toBe('es');
});
@ -744,8 +757,8 @@ describe('createEngineLang — setLogger()', () => {
const lang = createEngineLang(schema, 'es');
const warn1 = vi.fn();
const warn2 = vi.fn();
lang.setLogger({ warn: warn1, error: vi.fn() });
lang.setLogger({ warn: warn2, error: vi.fn() });
lang.setLogger(createTestLogger({ warn: warn1 }));
lang.setLogger(createTestLogger({ warn: warn2 }));
expect(lang.getDefaultLocale()).toBe('es');
});
});
@ -808,7 +821,7 @@ describe('createEngineLang — BCP 47 locales', () => {
it('bare locale with multiple siblings emits the ambiguous warning in DEV', () => {
const lang = createEngineLang(onlySiblings, 'es-MX');
const warn = vi.fn();
lang.setLogger({ warn, error: vi.fn() });
lang.setLogger(createTestLogger({ warn }));
const result = lang.t('greeting', undefined, 'es');
expect(result).toBe('Qué onda');
expect(warn).toHaveBeenCalledOnce();
@ -820,7 +833,7 @@ describe('createEngineLang — BCP 47 locales', () => {
const single = { greeting: { 'es-MX': 'Qué onda' } } as const satisfies LangNode;
const lang = createEngineLang(single, 'es-MX');
const warn = vi.fn();
lang.setLogger({ warn, error: vi.fn() });
lang.setLogger(createTestLogger({ warn }));
lang.t('greeting', undefined, 'es');
// MISSING_TRANSLATION fires (used 'es-MX' instead of 'es') but never the
// ambiguous-sibling warning.

@ -1,5 +1,6 @@
import type { ID_PREFIX } from './consts.ts';
import type { PluralCategory } from './plural_rules.ts';
import type { Logger } from '$libs/logr';
/**
* Closed set of base languages supported by the engine. Region tags are not
@ -105,7 +106,7 @@ export type EngineLang<S extends LangNode = LangNode> = {
namespace: NS,
module: M
) => EngineLang<S & { [K in NS]: M }>;
setLogger: (logger: LangLogger) => void;
setLogger: (logger: Logger) => void;
getDefaultLocale: () => SupportedLocale;
getFallbackChain: () => SupportedLocale[] | undefined;
};
@ -131,13 +132,8 @@ export type ActiveLang<S extends LangNode = LangNode> = {
namespace: NS,
module: M
) => ActiveLang<S & { [K in NS]: M }>;
setLogger: (logger: LangLogger) => void;
setLogger: (logger: Logger) => void;
getDefaultLocale: () => SupportedLocale;
getFallbackChain: () => SupportedLocale[] | undefined;
dispose: () => void;
};
export interface LangLogger {
warn: (category: string, message: string) => void;
error: (category: string, message: string) => void;
}

@ -1,6 +1,6 @@
import type { LevelsConfig, LogEntry, Transport } from '../types.ts';
import { LogLevel } from '../types.ts';
import { LEVEL_LABELS, levelsAtLeast } from '../consts.ts';
import { LEVEL_LABELS, LOGR_ERRORS, levelsAtLeast } from '../consts.ts';
export interface LokiTransportOptions {
/** Absolute URL of the push endpoint. Typically `https://<host>/loki/api/v1/push`. */
@ -89,7 +89,7 @@ export function lokiTransport(options: LokiTransportOptions): Transport {
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Loki push failed: ${res.status} ${res.statusText} ${body}`);
throw new Error(LOGR_ERRORS.LOKI_PUSH_FAILED(res.status, res.statusText, body));
}
}
@ -133,4 +133,3 @@ export function lokiTransport(options: LokiTransportOptions): Transport {
}
};
}

@ -94,3 +94,10 @@ export const LEVEL_CONSOLE_METHOD: Readonly<
[LogLevel.ERROR]: 'error',
[LogLevel.FATAL]: 'error'
};
export const LOGR_ERRORS = {
HTTP_TRANSPORT_PUSH_FAILED: (status: number, statusText: string, body: string): string =>
`http transport push failed: ${status} ${statusText} ${body}`,
LOKI_PUSH_FAILED: (status: number, statusText: string, body: string): string =>
`Loki push failed: ${status} ${statusText} ${body}`
} as const;

@ -374,7 +374,24 @@ function buildLogger(
handleFailure(transport, err, entries, context);
}
} else {
for (const item of buf) writeOne(transport, item.entry, item.context);
void flushBufferedEntriesWithoutBatch(transport, buf, entries, context);
}
}
async function flushBufferedEntriesWithoutBatch(
transport: Transport,
buf: BufferedLogEntry[],
entries: readonly LogEntry[],
context: DispatchContext
): Promise<void> {
for (const item of buf) {
try {
const ret = transport.write(item.entry);
if (ret && typeof ret.then === 'function') await ret;
} catch (err) {
handleFailure(transport, err, entries, context);
return;
}
}
}
@ -438,9 +455,7 @@ function buildLogger(
category: ENGINE_NAME,
message,
context:
suppressedCount > 0
? { ...(baseContext ?? {}), suppressedCount, throttleMs }
: baseContext,
suppressedCount > 0 ? { ...(baseContext ?? {}), suppressedCount, throttleMs } : baseContext,
error: errorObj,
tags: [ENGINE_NAME, 'transport-failure'],
deniedFor: [...context.deniedFor]

@ -20,6 +20,8 @@ export { consoleTransport, httpTransport, callbackTransport } from './transports
export { LogLevel } from './types.ts';
export type {
MessageCategory,
Logger,
LogFn,
LogMessage,
LogError,
LogSource,
@ -32,6 +34,5 @@ export type {
ConsoleTransportOptions,
HttpTransportOptions,
LoggerOptions,
LogFn,
EngineLogger
} from './types.ts';

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.