diff --git a/AUDIT_KIMI.md b/AUDIT_KIMI.md
new file mode 100644
index 0000000..a1c6704
--- /dev/null
+++ b/AUDIT_KIMI.md
@@ -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
+
+
+
+
+```
+
+**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
+
+
+
+
+ onToggle?.(id)}
+ class="checkbox"
+ />
+ {text}
+
+
+```
+
+**✅ 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
+
+
+```
+
+**⚠️ Patrón a Mejorar - Estado Global:**
+
+```typescript
+// stores/todoStore.ts
+// ✅ Svelte 5 runes store (moderno)
+
+function createTodoStore() {
+ let todos = $state([]);
+ 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
+
+
+
+
+```
+
+---
+
+## 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
+
+
+
+{#await HeavyChart then { default: Chart }}
+
+{/await}
+
+
+
+
+
+ {items[index].text}
+
+```
+
+### 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
+
+{#each todos as todo (todo.id)}
+
+
+{/each}
+
+
+
+
+
+```
+
+---
+
+## 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if errors.email}
+
+ {errors.email}
+
+ {/if}
+
+
+
+
+ {announcement}
+
+```
+
+### 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
+
+
+
+
+
+
+
+
+ {#if error}
+
+ {error}
+
+ {/if}
+
+```
+
+### 10.2 Hook Personalizado: useAsync
+
+```typescript
+// hooks/useAsync.ts
+import { $state, $derived } from 'svelte';
+
+interface AsyncState {
+ data: T | null;
+ loading: boolean;
+ error: Error | null;
+}
+
+export function useAsync(
+ asyncFn: () => Promise,
+ immediate = true
+) {
+ let state = $state>({
+ 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*
diff --git a/AUDIT_OPENCODE.md b/AUDIT_OPENCODE.md
new file mode 100644
index 0000000..0095e95
--- /dev/null
+++ b/AUDIT_OPENCODE.md
@@ -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 ``, 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` pero lo castea `as unknown as ActiveLang`. 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` 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` 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` 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(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`.
+
+### 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
diff --git a/AUDIT_claude.md b/AUDIT_claude.md
new file mode 100644
index 0000000..ac926f2
--- /dev/null
+++ b/AUDIT_claude.md
@@ -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`, 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` 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`. 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>>`
+ 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` 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.
diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md
new file mode 100644
index 0000000..ce091b4
--- /dev/null
+++ b/NEXT_STEPS.md
@@ -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.
diff --git a/src/arts/README.md b/src/arts/README.md
index bf51919..46954ef 100644
--- a/src/arts/README.md
+++ b/src/arts/README.md
@@ -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 `Diagnostics` with
+ `createDiagnostics(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` 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 |
diff --git a/src/arts/aapp/README.md b/src/arts/aapp/README.md
index 9344492..a204e0d 100644
--- a/src/arts/aapp/README.md
+++ b/src/arts/aapp/README.md
@@ -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
diff --git a/src/arts/aapp/active-app.svelte.ts b/src/arts/aapp/active-app.svelte.ts
index bfdce80..5c55e8f 100644
--- a/src/arts/aapp/active-app.svelte.ts
+++ b/src/arts/aapp/active-app.svelte.ts
@@ -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(
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(
return built;
},
- createActiveAuth(authOptions: Omit = {}): ActiveAuth {
+ createActiveAuth(
+ authOptions: Omit = {}
+ ): 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;
diff --git a/src/arts/aapp/consts.ts b/src/arts/aapp/consts.ts
index 5d46cbb..b247f94 100644
--- a/src/arts/aapp/consts.ts
+++ b/src/arts/aapp/consts.ts
@@ -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.';
diff --git a/src/arts/aapp/test/active-app.test.ts b/src/arts/aapp/test/active-app.test.ts
index 663a342..b7ed878 100644
--- a/src/arts/aapp/test/active-app.test.ts
+++ b/src/arts/aapp/test/active-app.test.ts
@@ -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({
diff --git a/src/arts/aapp/types.ts b/src/arts/aapp/types.ts
index 9aeaccf..b27f9d3 100644
--- a/src/arts/aapp/types.ts
+++ b/src/arts/aapp/types.ts
@@ -136,7 +136,7 @@ export interface ActiveAppOptions {
* automatically; the authoritative auth engine still lives server-side
* under `$svrs/auth`.
*/
- auth?: Omit;
+ auth?: Omit;
}
/**
@@ -252,7 +252,7 @@ export interface ActiveApp {
* reflects `/api/auth/*` state, sends CSRF headers and exposes pending /
* error state for UI.
*/
- createActiveAuth(options?: Omit): ActiveAuth;
+ createActiveAuth(options?: Omit): ActiveAuth;
/**
* The active session, when one has been built via
diff --git a/src/arts/auth/active-auth.svelte.ts b/src/arts/auth/active-auth.svelte.ts
index ceca6da..8c7ba60 100644
--- a/src/arts/auth/active-auth.svelte.ts
+++ b/src/arts/auth/active-auth.svelte.ts
@@ -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(options.initial ?? createAnonymousAuthCurrent());
let loading = $state(false);
let lastError = $state(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,
reason: AuthEventName
): Promise {
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);
}
}
diff --git a/src/arts/auth/consts.ts b/src/arts/auth/consts.ts
index 45fc73f..32b8493 100644
--- a/src/arts/auth/consts.ts
+++ b/src/arts/auth/consts.ts
@@ -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';
diff --git a/src/arts/auth/diagnostics.ts b/src/arts/auth/diagnostics.ts
new file mode 100644
index 0000000..4469223
--- /dev/null
+++ b/src/arts/auth/diagnostics.ts
@@ -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;
+
+const AUTH_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog = {
+ [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
+ });
+}
diff --git a/src/arts/auth/index.ts b/src/arts/auth/index.ts
index 8ecb18b..ed84ba5 100644
--- a/src/arts/auth/index.ts
+++ b/src/arts/auth/index.ts
@@ -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,
diff --git a/src/arts/auth/types.ts b/src/arts/auth/types.ts
index 84df364..5bf2885 100644
--- a/src/arts/auth/types.ts
+++ b/src/arts/auth/types.ts
@@ -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;
}
diff --git a/src/arts/cach/active-cache.svelte.ts b/src/arts/cach/active-cache.svelte.ts
index a0cd970..ee578d5 100644
--- a/src/arts/cach/active-cache.svelte.ts
+++ b/src/arts/cach/active-cache.svelte.ts
@@ -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(task: () => Promise): Promise {
+ async function track(method: string, task: () => Promise): Promise {
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);
@@ -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,
diff --git a/src/arts/cach/consts.ts b/src/arts/cach/consts.ts
index f57f357..bc91107 100644
--- a/src/arts/cach/consts.ts
+++ b/src/arts/cach/consts.ts
@@ -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().';
diff --git a/src/arts/cach/diagnostics.ts b/src/arts/cach/diagnostics.ts
new file mode 100644
index 0000000..1a00c47
--- /dev/null
+++ b/src/arts/cach/diagnostics.ts
@@ -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;
+
+const ACTIVE_CACHE_DIAGNOSTIC_LOGS: DiagnosticCatalog = {
+ [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
+ });
+}
diff --git a/src/arts/cach/index.ts b/src/arts/cach/index.ts
index 06c0794..9381971 100644
--- a/src/arts/cach/index.ts
+++ b/src/arts/cach/index.ts
@@ -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';
diff --git a/src/arts/cach/types.ts b/src/arts/cach/types.ts
index 5e7bb41..67716b9 100644
--- a/src/arts/cach/types.ts
+++ b/src/arts/cach/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];
diff --git a/src/arts/conn/acks.ts b/src/arts/conn/acks.ts
new file mode 100644
index 0000000..d016ca0
--- /dev/null
+++ b/src/arts/conn/acks.ts
@@ -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) => void;
+}
+
+export interface ConnectionAckRegistry {
+ wait(id: string, timeoutMs: number): Promise>;
+ resolve(id: string, result: ConnectionAckResult): void;
+ resolveAll(result: ConnectionAckResult): void;
+ resolveFromFrame(frame: ConnectionFrame): void;
+ mapSendFailure(result: ConnectionSendResult): ConnectionAckResult;
+}
+
+export function createConnectionAckRegistry(
+ connectionName: string,
+ timers: TimerScheduler
+): ConnectionAckRegistry {
+ const pending = new Map();
+
+ function resolve(id: string, result: ConnectionAckResult): void {
+ const ack = pending.get(id);
+ if (ack === undefined) return;
+ pending.delete(id);
+ timers.cancel(ack.timer);
+ ack.resolve(result);
+ }
+
+ return {
+ wait(id: string, timeoutMs: number): Promise> {
+ const ackTimer = timerKey(connectionName, TIMER_KEY_ACK, id);
+ return new Promise>((resolveWaiter) => {
+ pending.set(id, {
+ timer: ackTimer,
+ resolve: resolveWaiter as (result: ConnectionAckResult) => void
+ });
+ timers.schedule(
+ ackTimer,
+ timeoutMs,
+ () => {
+ resolve(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
+ },
+ { replace: true }
+ );
+ });
+ },
+ resolve,
+ resolveAll(result: ConnectionAckResult): 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 {
+ 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 };
+ }
+ };
+}
diff --git a/src/arts/conn/browser-reconnect.ts b/src/arts/conn/browser-reconnect.ts
new file mode 100644
index 0000000..d867cea
--- /dev/null
+++ b/src/arts/conn/browser-reconnect.ts
@@ -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;
+ };
+}
diff --git a/src/arts/conn/channel-registry.ts b/src/arts/conn/channel-registry.ts
new file mode 100644
index 0000000..5976dcf
--- /dev/null
+++ b/src/arts/conn/channel-registry.ts
@@ -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 {
+ receive(topic: string, frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
+ getOrCreate(
+ name: string,
+ options: ConnectionChannelOptions,
+ connection: Connection>
+ ): ConnectionChannel;
+ channels(): readonly ConnectionChannel[];
+ has(name: string): boolean;
+ leave(name: string): Promise;
+ joinConfigured(
+ reconnect: boolean,
+ configured: Readonly>,
+ connection: Connection
+ ): Promise;
+ dispose(): void;
+}
+
+export function createConnectionChannelRegistry(
+ reportListenerError: (event: string, error: unknown) => void
+): ConnectionChannelRegistry {
+ const channels = new Map();
+
+ return {
+ receive(topic, frame, meta): void {
+ channels.get(topic)?.receive(frame, meta);
+ },
+ getOrCreate(
+ channelName: string,
+ channelOptions: ConnectionChannelOptions,
+ connection: Connection>
+ ): ConnectionChannel {
+ const existing = channels.get(channelName);
+ if (existing !== undefined) return existing as ConnectionChannel;
+ const created = createConnectionChannel(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 {
+ await channels.get(name)?.leave();
+ },
+ async joinConfigured(reconnect, configured, connection): Promise {
+ const entries = (configured ?? {}) as Record;
+ for (const channelName of Object.keys(entries)) {
+ const configuredOptions = entries[channelName] ?? {};
+ const current = this.getOrCreate(
+ channelName,
+ configuredOptions,
+ connection as unknown as Connection>
+ ) 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();
+ }
+ };
+}
diff --git a/src/arts/conn/connection-auth.ts b/src/arts/conn/connection-auth.ts
new file mode 100644
index 0000000..cc3ce91
--- /dev/null
+++ b/src/arts/conn/connection-auth.ts
@@ -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;
+
+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
+): 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 };
+}
diff --git a/src/arts/conn/connection-events.ts b/src/arts/conn/connection-events.ts
new file mode 100644
index 0000000..7291e1b
--- /dev/null
+++ b/src/arts/conn/connection-events.ts
@@ -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();
+ const globalListeners = new Set();
+
+ 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();
+ }
+ };
+}
diff --git a/src/arts/conn/connection-state.ts b/src/arts/conn/connection-state.ts
new file mode 100644
index 0000000..896155e
--- /dev/null
+++ b/src/arts/conn/connection-state.ts
@@ -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);
+ }
+ };
+}
diff --git a/src/arts/conn/connection-timers.ts b/src/arts/conn/connection-timers.ts
new file mode 100644
index 0000000..2fe2738
--- /dev/null
+++ b/src/arts/conn/connection-timers.ts
@@ -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, 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
+ });
+ }
+ };
+}
diff --git a/src/arts/conn/connection.ts b/src/arts/conn/connection.ts
index 75e9683..d4a7692 100644
--- a/src/arts/conn/connection.ts
+++ b/src/arts/conn/connection.ts
@@ -1,90 +1,60 @@
-import { computeBackoffDelay } from '$libs/timers';
import type { TimerScheduler } from '$timr';
-import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts';
+import { createConnectionAckRegistry } from './acks.ts';
+import { wireBrowserReconnect } from './browser-reconnect.ts';
+import { createConnectionChannelRegistry } from './channel-registry.ts';
+import {
+ mapConnectionAckToAuthResult,
+ missingConnectionAuthProvider,
+ resolveConnectionAuth
+} from './connection-auth.ts';
+import { createConnectionEventBus } from './connection-events.ts';
+import { createConnectionStateTracker } from './connection-state.ts';
+import { createConnectionTimerControls } from './connection-timers.ts';
import {
- BROWSER_EVENT_ONLINE,
- BROWSER_EVENT_VISIBILITY_CHANGE,
CONNECTION_ACK_REASON_CLOSED,
- CONNECTION_ACK_REASON_REJECTED,
- CONNECTION_ACK_REASON_TIMEOUT,
- CONNECTION_ACK_REASON_TRANSPORT_ERROR,
CONNECTION_AUTH_REASON_CLOSED,
- CONNECTION_AUTH_REASON_NO_PROVIDER,
CONNECTION_AUTH_REASON_REJECTED,
- CONNECTION_AUTH_REASON_TIMEOUT,
- CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
- CONNECTION_BUFFER_POLICY_BUFFER,
- CONNECTION_BUFFER_POLICY_DROP,
CONNECTION_CLOSE_REASON_DISCONNECT,
CONNECTION_CLOSE_REASON_DISPOSE,
CONNECTION_CLOSE_REASON_AUTH_FAILED,
- CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT,
CONNECTION_CLOSE_REASON_RECONNECT,
CONNECTION_CLOSE_REASON_SESSION_EXPIRED,
CONNECTION_CONNECT_REASON_AUTH_FAILED,
CONNECTION_CONNECT_REASON_DISPOSED,
CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
- CONNECTION_FRAME_TYPE_AUTH,
- CONNECTION_FRAME_TYPE_PING,
CONNECTION_FRAME_TYPE_PONG,
CONNECTION_ID_PREFIX,
CONNECTION_ID_SEPARATOR,
- 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,
+ CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_CLOSING,
CONNECTION_STATE_CONNECTING,
CONNECTION_STATE_FAILED,
- CONNECTION_STATE_IDLE,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING,
CONNECTION_TRANSPORT_STATE_OPEN,
DEFAULT_ACK_TIMEOUT_MS,
- DEFAULT_BUFFER_MAX_BYTES,
- DEFAULT_BUFFER_MAX_MESSAGES,
- DEFAULT_HEARTBEAT_ENABLED,
- DEFAULT_HEARTBEAT_INTERVAL_MS,
- DEFAULT_HEARTBEAT_TIMEOUT_MS,
- DEFAULT_RECONNECT_ENABLED,
- DEFAULT_RECONNECT_FACTOR,
- DEFAULT_RECONNECT_JITTER_MS,
- DEFAULT_RECONNECT_MAX_DELAY_MS,
- DEFAULT_RECONNECT_MIN_DELAY_MS,
- DEFAULT_RECONNECT_ON_ONLINE,
- DEFAULT_RECONNECT_ON_VISIBLE,
- DOCUMENT_VISIBILITY_VISIBLE,
- 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,
- SESSION_EVENT_EXPIRED,
- SESSION_EVENT_REFRESHED,
- SESSION_EVENT_REVOKED,
- TIMER_KEY_ACK,
- TIMER_KEY_HEARTBEAT,
- TIMER_KEY_HEARTBEAT_TIMEOUT,
TIMER_KEY_RECONNECT
} from './consts.ts';
-import { listenerThrewMessage, loggerScope, timerKey } from './helpers.ts';
+import {
+ createConnectionDiagnostics,
+ emitConnectionDiagnostic,
+ emitScopedConnectionDiagnostic
+} from './diagnostics.ts';
+import { createConnectionFrameBuffer } from './frame-buffer.ts';
+import { loggerScope } from './helpers.ts';
+import { createConnectionHeartbeat } from './heartbeat.ts';
+import { createConnectionReconnectPolicy } from './reconnect.ts';
+import { wireConnectionSession } from './session-wiring.ts';
+import { createConnectionSender } from './sender.ts';
import { assertConnectionFrame, createFrame } from './serializer.ts';
import { jsonConnectionSerializer } from './serializers/json.ts';
+import { attachConnectionTransport } from './transport-wiring.ts';
+import type { Logger } from '$libs/logr';
import type {
Connection,
ConnectionAckResult,
- ConnectionAuthPayload,
ConnectionAuthResult,
ConnectionChannel,
ConnectionChannelMap,
@@ -92,31 +62,20 @@ import type {
ConnectionCloseEvent,
ConnectionConnectResult,
ConnectionFrame,
- ConnectionLogger,
ConnectionMessageMeta,
ConnectionOptions,
ConnectionRequestOptions,
- ConnectionSendResult,
ConnectionSessionSource,
ConnectionState,
- ConnectionStateChange,
ConnectionTransport
} from './types.ts';
interface ConnectionRuntime {
- readonly logger?: ConnectionLogger;
+ readonly logger?: Logger;
readonly timers: TimerScheduler;
readonly session?: ConnectionSessionSource;
}
-interface PendingAck {
- readonly timer: string;
- readonly resolve: (result: ConnectionAckResult) => void;
-}
-
-type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void;
-type StateListener = (change: ConnectionStateChange) => void;
-
function createIdFactory(connectionName: string): () => string {
let next = 0;
return () => {
@@ -125,122 +84,71 @@ function createIdFactory(connectionName: string): () => string {
};
}
-function isPromiseLike(value: unknown): value is PromiseLike {
- return typeof value === 'object' && value !== null && 'then' in value;
-}
-
-function isClosedLike(state: ConnectionState): boolean {
- return (
- state === CONNECTION_STATE_CLOSED ||
- state === CONNECTION_STATE_FAILED ||
- state === CONNECTION_STATE_IDLE
- );
-}
-
export function createConnection(
name: string,
options: ConnectionOptions,
runtime: ConnectionRuntime
): Connection {
const serializer = options.serializer ?? jsonConnectionSerializer;
- const reconnectDisabled = options.reconnect === false;
- const reconnectOptions = reconnectDisabled ? undefined : options.reconnect;
- const heartbeatOptions = options.heartbeat === false ? false : options.heartbeat;
- const bufferOptions = options.buffer ?? {};
- const stateListeners = new Set();
- const globalListeners = new Set();
- const pendingAcks = new Map();
- const bufferedFrames: ConnectionFrame[] = [];
- const channels = new Map();
- const detachBrowserListeners: (() => void)[] = [];
+ const reconnectPolicy = createConnectionReconnectPolicy(options.reconnect);
+ const frameBuffer = createConnectionFrameBuffer(options.buffer);
+ const acks = createConnectionAckRegistry(name, runtime.timers);
+ const timers = createConnectionTimerControls(name, runtime.timers);
const nextId = createIdFactory(name);
const scope = loggerScope(name, options.loggerScope);
+ const diagnostics = createConnectionDiagnostics({ logger: runtime.logger, scope });
+ const events = createConnectionEventBus({ diagnostics });
+ const stateTracker = createConnectionStateTracker({
+ name,
+ now: () => runtime.timers.clock.now(),
+ emitState: (change) => events.emitState(change)
+ });
+ const channelRegistry = createConnectionChannelRegistry(reportChannelListenerError);
+ const sender = createConnectionSender({
+ serializer,
+ frameBuffer,
+ isConnected,
+ getTransport: () => transport,
+ setError: (err) => {
+ stateTracker.setError(err);
+ },
+ diagnostics
+ });
let transport: ConnectionTransport | null = null;
- let detachTransportListeners: (() => void)[] = [];
- 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;
+ let detachTransportListeners = (): void => {};
let disposed = false;
let intentionalClose = false;
let connectPromise: Promise | null = null;
- function now(): number {
- return runtime.timers.clock.now();
- }
-
- function logDebug(message: string, meta?: unknown): void {
- runtime.logger?.debug?.(scope, message, meta);
- }
-
- function logWarn(message: string, meta?: unknown): void {
- runtime.logger?.warn?.(scope, message, meta);
- }
-
- function logError(message: string, meta?: unknown): void {
- runtime.logger?.error?.(scope, message, meta);
- }
-
- function emitState(to: ConnectionState, changeError?: unknown): void {
- if (state === to && changeError === undefined) return;
- const from = state;
- state = to;
- const change: ConnectionStateChange = {
- connection: name,
- from,
- to,
- generation,
- error: changeError,
- at: now()
- };
- for (const listener of [...stateListeners]) {
- try {
- listener(change);
- } catch (err) {
- logError(listenerThrewMessage(to), { error: err });
- }
- }
- }
-
function resolveTransport(): ConnectionTransport {
return typeof options.transport === 'function' ? options.transport() : options.transport;
}
function detachTransport(): void {
- for (const detach of detachTransportListeners) detach();
- detachTransportListeners = [];
+ detachTransportListeners();
+ detachTransportListeners = (): void => {};
}
function attachTransport(next: ConnectionTransport): void {
detachTransport();
- detachTransportListeners = [
- next.onOpen(handleTransportOpen),
- next.onMessage(handleTransportMessage),
- next.onClose(handleTransportClose),
- next.onError(handleTransportError)
- ];
+ detachTransportListeners = attachConnectionTransport(next, {
+ onOpen: handleTransportOpen,
+ onMessage: handleTransportMessage,
+ onClose: handleTransportClose,
+ onError: handleTransportError
+ });
}
function markOpen(): void {
- if (state === CONNECTION_STATE_OPEN) return;
- generation += 1;
- error = null;
- openedAt = now();
- closedAt = null;
- reconnectAttempt = 0;
- emitState(CONNECTION_STATE_OPEN);
- startHeartbeat();
+ if (stateTracker.markOpen()) heartbeat.start();
}
function markClosed(nextState: ConnectionState, cause?: unknown): void {
- closedAt = now();
- stopHeartbeat();
- resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
- emitState(nextState, cause);
+ stateTracker.markClosed(nextState, cause, () => {
+ heartbeat.stop();
+ acks.resolveAll({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
+ });
}
function handleTransportOpen(): void {
@@ -248,20 +156,22 @@ export function createConnection void | Promise,
- id?: string
- ): void {
- runtime.timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true });
- }
-
- function scheduleInterval(kind: string, everyMs: number, task: () => void, id?: string): void {
- runtime.timers.interval(timerKey(name, kind, id), everyMs, task, {
- replace: true,
- awaitTask: false
+ stateTracker.setError(err);
+ emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.TRANSPORT_ERROR, {
+ error: err
});
}
+ const heartbeat = createConnectionHeartbeat({
+ heartbeatOptions: options.heartbeat === false ? false : options.heartbeat,
+ isConnected,
+ sendFrame: sender.sendFrame,
+ scheduleInterval: timers.interval,
+ scheduleTimer: timers.schedule,
+ cancelTimer: timers.cancel,
+ closeTransport: (reason) => {
+ transport?.close(undefined, reason);
+ },
+ diagnostics
+ });
+
function shouldReconnect(): boolean {
- if (disposed || intentionalClose) return false;
- if (reconnectDisabled) return false;
- return reconnectOptions?.enabled ?? DEFAULT_RECONNECT_ENABLED;
+ return reconnectPolicy.isEnabled({ disposed, intentionalClose });
}
function scheduleReconnect(): void {
if (!shouldReconnect()) return;
- const maxAttempts = reconnectOptions?.maxAttempts;
- if (maxAttempts !== undefined && reconnectAttempt >= maxAttempts) {
- logWarn(LOG_MSG_RECONNECT_EXHAUSTED, { reconnectAttempt, maxAttempts });
- error = new Error(LOG_MSG_RECONNECT_EXHAUSTED);
- emitState(CONNECTION_STATE_FAILED, error);
+ const nextReconnect = reconnectPolicy.next(stateTracker.reconnectAttempt);
+ if (!nextReconnect.ok) {
+ emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.RECONNECT_EXHAUSTED, {
+ reconnectAttempt: nextReconnect.reconnectAttempt,
+ maxAttempts: nextReconnect.maxAttempts
+ });
+ const exhaustedError = new Error(LOG_MSG_RECONNECT_EXHAUSTED);
+ stateTracker.setError(exhaustedError);
+ stateTracker.transition(CONNECTION_STATE_FAILED, exhaustedError);
return;
}
- reconnectAttempt += 1;
- const delayMs = computeBackoffDelay(reconnectAttempt - 1, {
- minDelayMs: reconnectOptions?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS,
- maxDelayMs: reconnectOptions?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
- factor: reconnectOptions?.factor ?? DEFAULT_RECONNECT_FACTOR,
- jitterMs: reconnectOptions?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS
- });
- scheduleTimer(TIMER_KEY_RECONNECT, delayMs, () => {
+ stateTracker.setReconnectAttempt(nextReconnect.attempt);
+ timers.schedule(TIMER_KEY_RECONNECT, nextReconnect.delayMs, () => {
void connectInternal(true);
});
}
- function startHeartbeat(): void {
- if (heartbeatOptions === false) return;
- if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return;
- const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
- scheduleInterval(TIMER_KEY_HEARTBEAT, intervalMs, () => {
- void sendHeartbeat();
- });
- }
-
- function stopHeartbeat(): void {
- cancelTimer(TIMER_KEY_HEARTBEAT);
- cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
- }
-
- async function sendHeartbeat(): Promise {
- if (!isConnected()) return;
- const pingType = heartbeatOptions === false ? undefined : heartbeatOptions?.pingType;
- const result = await sendFrame(
- createFrame({
- type: pingType ?? CONNECTION_FRAME_TYPE_PING,
- payload: undefined
- }),
- false
- );
- if (!result.ok) return;
- const timeoutMs =
- heartbeatOptions === false
- ? DEFAULT_HEARTBEAT_TIMEOUT_MS
- : (heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS);
- scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => {
- logWarn(LOG_MSG_HEARTBEAT_TIMEOUT);
- transport?.close(undefined, CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT);
- });
- }
-
function isConnected(): boolean {
- return state === CONNECTION_STATE_OPEN && transport?.state === CONNECTION_TRANSPORT_STATE_OPEN;
- }
-
- function resolvePendingAcks(result: ConnectionAckResult): void {
- for (const id of [...pendingAcks.keys()]) resolveAck(id, result);
- }
-
- function resolveAck(id: string, result: ConnectionAckResult): void {
- const pending = pendingAcks.get(id);
- if (pending === undefined) return;
- pendingAcks.delete(id);
- runtime.timers.cancel(pending.timer);
- pending.resolve(result);
- }
-
- function resolveAckFromFrame(frame: ConnectionFrame): void {
- if (frame.replyTo === undefined) return;
- if (frame.error !== undefined) {
- resolveAck(frame.replyTo, {
- ok: false,
- reason: CONNECTION_ACK_REASON_REJECTED,
- error: frame.error
- });
- return;
- }
- resolveAck(frame.replyTo, { ok: true, payload: frame.payload });
- }
-
- function mapSendFailureToAck(result: ConnectionSendResult): ConnectionAckResult {
- 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 };
- }
-
- function canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean {
- if (!allowBuffer) return false;
- if (frame.ack === true) return false;
- return bufferOptions.policy === CONNECTION_BUFFER_POLICY_BUFFER;
- }
-
- function bufferFrame(frame: ConnectionFrame): ConnectionSendResult {
- const maxMessages = bufferOptions.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES;
- if (bufferedFrames.length >= maxMessages) {
- return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
- }
- bufferedFrames.push(frame);
- return { ok: true, id: frame.id };
- }
-
- async function flushBuffer(): Promise {
- while (isConnected() && bufferedFrames.length > 0) {
- const frame = bufferedFrames.shift();
- if (frame === undefined) return;
- const result = await sendFrame(frame, false);
- if (!result.ok) {
- bufferedFrames.unshift(frame);
- return;
- }
- }
- }
-
- async function sendFrame(
- frame: ConnectionFrame,
- allowBuffer: boolean
- ): Promise {
- try {
- createFrame(frame);
- } catch (err) {
- return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err };
- }
-
- if (!isConnected()) {
- if (canBuffer(frame, allowBuffer)) return bufferFrame(frame);
- if (bufferOptions.policy === CONNECTION_BUFFER_POLICY_DROP) {
- return { ok: true, id: frame.id };
- }
- return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED };
- }
-
- const currentTransport = transport;
- if (currentTransport === null || !currentTransport.canSend) {
- return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED };
- }
- const maxBytes = bufferOptions.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES;
- if (currentTransport.bufferedAmount > maxBytes) {
- return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
- }
-
- let encoded: string | ArrayBuffer;
- try {
- encoded = serializer.encode(frame);
- } catch (err) {
- logError(LOG_MSG_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) {
- error = err;
- logError(LOG_MSG_SEND_FAILED, { error: err });
- return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err };
- }
- }
-
- function getAuthProvider():
- | (() => ConnectionAuthPayload | null | Promise)
- | null {
- if (typeof options.auth === 'function') return options.auth;
- return options.auth?.getAuth ?? null;
+ return (
+ stateTracker.state === CONNECTION_STATE_OPEN &&
+ transport?.state === CONNECTION_TRANSPORT_STATE_OPEN
+ );
}
async function runAuth(): Promise {
if (!isConnected()) return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED };
- const provider = getAuthProvider();
- if (provider === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
- let payload: ConnectionAuthPayload | null;
+ const auth = resolveConnectionAuth(options.auth);
+ if (auth.provider === null) return missingConnectionAuthProvider();
try {
- payload = await provider();
+ const payload = await auth.provider();
+ if (payload === null) return missingConnectionAuthProvider();
+ const result = await requestFrame(auth.authType, payload, { timeoutMs: auth.timeoutMs });
+ return mapConnectionAckToAuthResult(result);
} catch (err) {
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: err };
}
- if (payload === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
- const authOptions = typeof options.auth === 'function' ? undefined : options.auth;
- const authType =
- typeof options.auth === 'function' ? CONNECTION_FRAME_TYPE_AUTH : authOptions?.authType;
- const result = await requestFrame(
- authType ?? CONNECTION_FRAME_TYPE_AUTH,
- payload,
- { timeoutMs: authOptions?.timeoutMs }
- );
- 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 };
}
async function requestFrame(
@@ -556,96 +284,68 @@ export function createConnection>((resolve) => {
- pendingAcks.set(id, {
- timer: ackTimer,
- resolve: resolve as (result: ConnectionAckResult) => void
- });
- runtime.timers.schedule(
- ackTimer,
- timeoutMs,
- () => {
- resolveAck(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
- },
- { replace: true }
- );
- });
- const result = await sendFrame(frame, requestOptions.buffer === true);
- if (!result.ok) resolveAck(id, mapSendFailureToAck(result));
+ const promise = acks.wait(id, timeoutMs);
+ const result = await sender.sendFrame(frame, requestOptions.buffer === true);
+ if (!result.ok) acks.resolve(id, acks.mapSendFailure(result));
return promise;
}
async function joinConfiguredChannels(reconnect: boolean): Promise {
- const configured = (options.channels ?? {}) as Record<
- string,
- ConnectionChannelOptions | undefined
- >;
- for (const channelName of Object.keys(configured)) {
- const configuredOptions = configured[channelName] ?? {};
- const current = connection.channel(
- channelName,
- configuredOptions
- ) 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();
- }
+ await channelRegistry.joinConfigured(reconnect, options.channels ?? {}, connection);
}
async function connectInternal(reconnect: boolean): Promise {
if (disposed) {
return {
ok: false,
- state,
+ state: stateTracker.state,
reason: CONNECTION_CONNECT_REASON_DISPOSED
};
}
- if (state === CONNECTION_STATE_OPEN && isConnected()) {
- return { ok: true, state, reused: true };
+ if (stateTracker.state === CONNECTION_STATE_OPEN && isConnected()) {
+ return { ok: true, state: stateTracker.state, reused: true };
}
if (connectPromise !== null) return connectPromise;
connectPromise = (async () => {
intentionalClose = false;
- cancelTimer(TIMER_KEY_RECONNECT);
+ timers.cancel(TIMER_KEY_RECONNECT);
const nextTransport = resolveTransport();
transport = nextTransport;
attachTransport(nextTransport);
- emitState(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING);
+ stateTracker.transition(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING);
try {
await nextTransport.open();
markOpen();
- if (getAuthProvider() !== null) {
+ if (resolveConnectionAuth(options.auth).provider !== null) {
const auth = await runAuth();
if (!auth.ok) {
- error = auth.error ?? auth.reason;
- logWarn(LOG_MSG_AUTH_FAILED, auth);
+ const authError = auth.error ?? auth.reason;
+ stateTracker.setError(authError);
+ emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.AUTH_FAILED, auth);
detachTransport();
transport?.close(undefined, CONNECTION_CLOSE_REASON_AUTH_FAILED);
- markClosed(CONNECTION_STATE_FAILED, error);
+ markClosed(CONNECTION_STATE_FAILED, authError);
return {
ok: false,
- state,
+ state: stateTracker.state,
reason: CONNECTION_CONNECT_REASON_AUTH_FAILED,
- error
+ error: authError
};
}
}
- await flushBuffer();
+ await sender.flushBuffer();
await joinConfiguredChannels(reconnect);
- return { ok: true, state };
+ return { ok: true, state: stateTracker.state };
} catch (err) {
- error = err;
- logError(LOG_MSG_CONNECT_FAILED, { error: err });
- emitState(CONNECTION_STATE_FAILED, err);
+ stateTracker.setError(err);
+ emitConnectionDiagnostic(diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.CONNECT_FAILED, {
+ error: err
+ });
+ stateTracker.transition(CONNECTION_STATE_FAILED, err);
return {
ok: false,
- state,
+ state: stateTracker.state,
reason: CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
error: err
};
@@ -659,118 +359,51 @@ export function createConnection 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 (intentionalClose || disposed || !isClosedLike(state)) return;
- logDebug(LOG_MSG_BROWSER_RECONNECT);
- void connectInternal(true);
- };
- target.addEventListener(BROWSER_EVENT_ONLINE, onOnline);
- detachBrowserListeners.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 (intentionalClose || disposed || !isClosedLike(state)) return;
- logDebug(LOG_MSG_BROWSER_RECONNECT);
- void connectInternal(true);
- };
- target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
- detachBrowserListeners.push(() => {
- target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
- });
- }
- }
-
- function wireSession(): () => void {
- const sessionOptions = options.session;
- if (
- sessionOptions === undefined ||
- sessionOptions === false ||
- sessionOptions.enabled === false
- ) {
- return () => {};
- }
- const source = runtime.session;
- if (source === undefined) return () => {};
- return source.onChange((change) => {
- if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) {
- logDebug(LOG_MSG_SESSION_REFRESHED);
- void connection.reauthenticate().then((result) => {
- if (!result.ok) logWarn(LOG_MSG_REAUTH_FAILED, result);
- });
- return;
- }
- if (
- (change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) &&
- sessionOptions.disconnectOnExpire !== false
- ) {
- logWarn(
- change.event === SESSION_EVENT_EXPIRED ? LOG_MSG_SESSION_EXPIRED : LOG_MSG_SESSION_REVOKED
- );
- connection.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED);
- }
- });
+ if (stateTracker.state !== CONNECTION_STATE_CLOSED) markClosed(CONNECTION_STATE_CLOSED);
}
function reportChannelListenerError(event: string, err: unknown): void {
- logError(listenerThrewMessage(event), { error: err });
+ emitScopedConnectionDiagnostic(
+ diagnostics,
+ scope,
+ CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
+ { error: err, event }
+ );
}
let detachSession = (): void => {};
+ let detachBrowserReconnect = (): void => {};
const connection: Connection = {
name,
get state() {
- return state;
+ return stateTracker.state;
},
get connected() {
return isConnected();
},
get generation() {
- return generation;
+ return stateTracker.generation;
},
get error() {
- return error;
+ return stateTracker.error;
},
get openedAt() {
- return openedAt;
+ return stateTracker.openedAt;
},
get closedAt() {
- return closedAt;
+ return stateTracker.closedAt;
},
get lastMessageAt() {
- return lastMessageAt;
+ return stateTracker.lastMessageAt;
},
get reconnectAttempt() {
- return reconnectAttempt;
+ return stateTracker.reconnectAttempt;
},
connect() {
return connectInternal(false);
@@ -783,7 +416,7 @@ export function createConnection {
- const existing = channels.get(channelName);
- if (existing !== undefined) return existing as ConnectionChannel;
- const created = createConnectionChannel(channelName, channelOptions, {
- connection: connection as unknown as Connection>,
- reportListenerError: reportChannelListenerError
- });
- channels.set(channelName, created as InternalConnectionChannel);
- return created;
+ return channelRegistry.getOrCreate(
+ channelName,
+ channelOptions,
+ connection as unknown as Connection>
+ );
},
channels() {
- return [...channels.values()];
+ return channelRegistry.channels();
},
hasChannel(channelName) {
- return channels.has(channelName);
+ return channelRegistry.has(channelName);
},
async leaveChannel(channelName) {
- await channels.get(channelName)?.leave();
+ await channelRegistry.leave(channelName);
},
onState(listener) {
- stateListeners.add(listener);
- return () => {
- stateListeners.delete(listener);
- };
+ return events.onState(listener);
},
onAny(listener) {
- globalListeners.add(listener);
- return () => {
- globalListeners.delete(listener);
- };
+ return events.onAny(listener);
},
dispose() {
if (disposed) return;
disposed = true;
closeTransport(CONNECTION_CLOSE_REASON_DISPOSE);
detachSession();
- for (const detach of detachBrowserListeners) detach();
- detachBrowserListeners.length = 0;
+ detachBrowserReconnect();
detachTransport();
- for (const current of channels.values()) current.dispose();
- channels.clear();
- stateListeners.clear();
- globalListeners.clear();
- bufferedFrames.length = 0;
+ channelRegistry.dispose();
+ events.clear();
+ frameBuffer.clear();
}
};
- detachSession = wireSession();
- wireBrowserReconnect();
+ detachSession = wireConnectionSession({
+ sessionOptions: options.session,
+ source: runtime.session,
+ reauthenticate: () => connection.reauthenticate(),
+ disconnect: (reason) => {
+ connection.disconnect(reason);
+ },
+ diagnostics
+ });
+ detachBrowserReconnect = wireBrowserReconnect({
+ disabled: reconnectPolicy.disabled,
+ reconnectOptions: reconnectPolicy.options,
+ shouldReconnect: () => !intentionalClose && !disposed && stateTracker.isClosedLike(),
+ reconnect: () => {
+ void connectInternal(true);
+ },
+ diagnostics
+ });
return connection;
}
diff --git a/src/arts/conn/consts.ts b/src/arts/conn/consts.ts
index 9cef362..a01b0a1 100644
--- a/src/arts/conn/consts.ts
+++ b/src/arts/conn/consts.ts
@@ -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: ';
diff --git a/src/arts/conn/diagnostics.ts b/src/arts/conn/diagnostics.ts
new file mode 100644
index 0000000..5cc38e9
--- /dev/null
+++ b/src/arts/conn/diagnostics.ts
@@ -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;
+export type ConnectionDiagnostics = Diagnostics;
+
+const CONNECTION_DIAGNOSTIC_LOGS: DiagnosticCatalog = {
+ [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
+ });
+}
diff --git a/src/arts/conn/errors.ts b/src/arts/conn/errors.ts
index 594273e..66f2df8 100644
--- a/src/arts/conn/errors.ts
+++ b/src/arts/conn/errors.ts
@@ -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
+ );
+}
diff --git a/src/arts/conn/frame-buffer.ts b/src/arts/conn/frame-buffer.ts
new file mode 100644
index 0000000..0584c85
--- /dev/null
+++ b/src/arts/conn/frame-buffer.ts
@@ -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;
+ }
+ };
+}
diff --git a/src/arts/conn/heartbeat.ts b/src/arts/conn/heartbeat.ts
new file mode 100644
index 0000000..621a20f
--- /dev/null
+++ b/src/arts/conn/heartbeat.ts
@@ -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,
+ allowBuffer: boolean
+ ): Promise;
+ scheduleInterval(kind: string, everyMs: number, task: () => void, id?: string): void;
+ scheduleTimer(kind: string, delayMs: number, task: () => void | Promise, 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 {
+ 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);
+ }
+ };
+}
diff --git a/src/arts/conn/index.ts b/src/arts/conn/index.ts
index f982fb5..059c312 100644
--- a/src/arts/conn/index.ts
+++ b/src/arts/conn/index.ts
@@ -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';
diff --git a/src/arts/conn/reconnect.ts b/src/arts/conn/reconnect.ts
new file mode 100644
index 0000000..04e5bfd
--- /dev/null
+++ b/src/arts/conn/reconnect.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
+ })
+ };
+ }
+ };
+}
diff --git a/src/arts/conn/sender.ts b/src/arts/conn/sender.ts
new file mode 100644
index 0000000..b807247
--- /dev/null
+++ b/src/arts/conn/sender.ts
@@ -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;
+ flushBuffer(): Promise;
+}
+
+export function createConnectionSender(runtime: ConnectionSenderRuntime): ConnectionSender {
+ async function sendFrame(
+ frame: ConnectionFrame,
+ allowBuffer: boolean
+ ): Promise {
+ 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 {
+ 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;
+ }
+ }
+ }
+ };
+}
diff --git a/src/arts/conn/session-wiring.ts b/src/arts/conn/session-wiring.ts
new file mode 100644
index 0000000..76f46ff
--- /dev/null
+++ b/src/arts/conn/session-wiring.ts
@@ -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;
+ 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);
+ }
+ });
+}
diff --git a/src/arts/conn/test/active-connections.test.ts b/src/arts/conn/test/active-connections.test.ts
new file mode 100644
index 0000000..b654e9b
--- /dev/null
+++ b/src/arts/conn/test/active-connections.test.ts
@@ -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();
+ });
+});
diff --git a/src/arts/conn/test/connection-state.test.ts b/src/arts/conn/test/connection-state.test.ts
new file mode 100644
index 0000000..c1ba173
--- /dev/null
+++ b/src/arts/conn/test/connection-state.test.ts
@@ -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']);
+ });
+});
diff --git a/src/arts/conn/test/connection.test.ts b/src/arts/conn/test/connection.test.ts
index e9bd476..cc47d0e 100644
--- a/src/arts/conn/test/connection.test.ts
+++ b/src/arts/conn/test/connection.test.ts
@@ -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();
diff --git a/src/arts/conn/test/websocket.test.ts b/src/arts/conn/test/websocket.test.ts
new file mode 100644
index 0000000..a10f06d
--- /dev/null
+++ b/src/arts/conn/test/websocket.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/arts/conn/transport-wiring.ts b/src/arts/conn/transport-wiring.ts
new file mode 100644
index 0000000..28532d1
--- /dev/null
+++ b/src/arts/conn/transport-wiring.ts
@@ -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();
+ };
+}
diff --git a/src/arts/conn/transports/websocket.ts b/src/arts/conn/transports/websocket.ts
index 0b1a4c4..76f816d 100644
--- a/src/arts/conn/transports/websocket.ts
+++ b/src/arts/conn/transports/websocket.ts
@@ -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;
}
diff --git a/src/arts/conn/types.ts b/src/arts/conn/types.ts
index c61c993..44f88b2 100644
--- a/src/arts/conn/types.ts
+++ b/src/arts/conn/types.ts
@@ -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;
export type ConnectionChannelMap = Record;
export type ConnectionMap = Record;
-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;
diff --git a/src/arts/fend/active-frontend.svelte.ts b/src/arts/fend/active-frontend.svelte.ts
index aa23e67..a53a5e0 100644
--- a/src/arts/fend/active-frontend.svelte.ts
+++ b/src/arts/fend/active-frontend.svelte.ts
@@ -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(
@@ -225,6 +227,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ
unsubscribeLocale?.();
unsubscribeDarkMode();
unsubscribeReducedMotion();
+ ownedDom?.dispose();
listeners.clear();
}
};
diff --git a/src/arts/fmts/active-formats.svelte.ts b/src/arts/fmts/active-formats.svelte.ts
index 78b5db7..90264fc 100644
--- a/src/arts/fmts/active-formats.svelte.ts
+++ b/src/arts/fmts/active-formats.svelte.ts
@@ -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 {
diff --git a/src/arts/fmts/active-runtime.svelte.ts b/src/arts/fmts/active-runtime.svelte.ts
new file mode 100644
index 0000000..3d96586
--- /dev/null
+++ b/src/arts/fmts/active-runtime.svelte.ts
@@ -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();
+ }
+ };
+}
diff --git a/src/arts/fmts/auto-state.ts b/src/arts/fmts/auto-state.ts
new file mode 100644
index 0000000..e3b6606
--- /dev/null
+++ b/src/arts/fmts/auto-state.ts
@@ -0,0 +1,46 @@
+export interface AutoStateOptions {
+ initial?: TMode;
+ auto: TMode;
+ normalize?: (value: TMode) => TMode;
+ toManual?: (value: TMode) => TValue;
+}
+
+export interface AutoState {
+ get: (resolveAuto: () => TValue) => TValue;
+ set: (value: TMode) => void;
+ clear: () => void;
+ isAuto: () => boolean;
+}
+
+export function createAutoState(
+ options: AutoStateOptions
+): AutoState {
+ 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;
+ }
+ };
+}
diff --git a/src/arts/fmts/curr/active-currency.svelte.ts b/src/arts/fmts/curr/active-currency.svelte.ts
index 7232228..905b2ff 100644
--- a/src/arts/fmts/curr/active-currency.svelte.ts
+++ b/src/arts/fmts/curr/active-currency.svelte.ts
@@ -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 before = engine.getCurrency();
- engine.setLocale(locale);
- const after = engine.getCurrency();
- bump();
- notifyLocale(engine.getLocale());
- if (before !== after) notifyCurrency(after);
- }
-
- const unsubscribeLocale = localeSource?.onLocaleChange?.(syncLocale);
+ const runtime = createActiveFormatsRuntime({
+ localeSource,
+ getLocale: () => engine.getLocale(),
+ setLocale(locale) {
+ const before = engine.getCurrency();
+ engine.setLocale(locale);
+ const after = engine.getCurrency();
+ if (before !== after) notifyCurrency(after);
+ }
+ });
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 {
- void version;
+ runtime.read();
return engine.convert(value, to);
},
convertAs(value: number, from: CurrencyCode, to: CurrencyCode): Promise {
- 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();
}
};
}
diff --git a/src/arts/fmts/curr/consts.ts b/src/arts/fmts/curr/consts.ts
index a824f06..1a02216 100644
--- a/src/arts/fmts/curr/consts.ts
+++ b/src/arts/fmts/curr/consts.ts
@@ -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';
diff --git a/src/arts/fmts/curr/diagnostics.ts b/src/arts/fmts/curr/diagnostics.ts
new file mode 100644
index 0000000..36fe408
--- /dev/null
+++ b/src/arts/fmts/curr/diagnostics.ts
@@ -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;
+
+const CURRENCY_DIAGNOSTIC_LOGS: DiagnosticCatalog = {
+ [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
+ });
+}
diff --git a/src/arts/fmts/curr/engine-currency.ts b/src/arts/fmts/curr/engine-currency.ts
index fa5cd1a..8278eb7 100644
--- a/src/arts/fmts/curr/engine-currency.ts
+++ b/src/arts/fmts/curr/engine-currency.ts
@@ -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({
+ 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;
}
diff --git a/src/arts/fmts/curr/index.ts b/src/arts/fmts/curr/index.ts
index 0d0d5c0..0f4a854 100644
--- a/src/arts/fmts/curr/index.ts
+++ b/src/arts/fmts/curr/index.ts
@@ -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,
diff --git a/src/arts/fmts/curr/rates.ts b/src/arts/fmts/curr/rates.ts
index 2dcd0a4..29094f3 100644
--- a/src/arts/fmts/curr/rates.ts
+++ b/src/arts/fmts/curr/rates.ts
@@ -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>();
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;
diff --git a/src/arts/fmts/curr/types.ts b/src/arts/fmts/curr/types.ts
index 6386469..a2f26fe 100644
--- a/src/arts/fmts/curr/types.ts
+++ b/src/arts/fmts/curr/types.ts
@@ -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;
- logger?: EngineLogger;
+ logger?: Logger;
format?: CurrencyFormatOptions;
}
diff --git a/src/arts/fmts/dates/active-dates.svelte.ts b/src/arts/fmts/dates/active-dates.svelte.ts
index a97c895..7d4b665 100644
--- a/src/arts/fmts/dates/active-dates.svelte.ts
+++ b/src/arts/fmts/dates/active-dates.svelte.ts
@@ -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
};
}
diff --git a/src/arts/fmts/dates/engine-dates.ts b/src/arts/fmts/dates/engine-dates.ts
index f2f7949..2b0908b 100644
--- a/src/arts/fmts/dates/engine-dates.ts
+++ b/src/arts/fmts/dates/engine-dates.ts
@@ -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({
+ initial: options.dateOrder,
+ auto: 'auto'
+ });
+ const hourCycleState = createAutoState({
+ 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) {
diff --git a/src/arts/fmts/dates/test/active-dates.test.ts b/src/arts/fmts/dates/test/active-dates.test.ts
new file mode 100644
index 0000000..b517e10
--- /dev/null
+++ b/src/arts/fmts/dates/test/active-dates.test.ts
@@ -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();
+ });
+});
diff --git a/src/arts/fmts/engine-formats.ts b/src/arts/fmts/engine-formats.ts
index 22542e0..2b27d67 100644
--- a/src/arts/fmts/engine-formats.ts
+++ b/src/arts/fmts/engine-formats.ts
@@ -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);
diff --git a/src/arts/fmts/locale-state.ts b/src/arts/fmts/locale-state.ts
new file mode 100644
index 0000000..880780b
--- /dev/null
+++ b/src/arts/fmts/locale-state.ts
@@ -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;
+ }
+ };
+}
diff --git a/src/arts/fmts/nums/active-numbers.svelte.ts b/src/arts/fmts/nums/active-numbers.svelte.ts
index 73dfd9f..0d69046 100644
--- a/src/arts/fmts/nums/active-numbers.svelte.ts
+++ b/src/arts/fmts/nums/active-numbers.svelte.ts
@@ -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
};
}
diff --git a/src/arts/fmts/nums/engine-numbers.ts b/src/arts/fmts/nums/engine-numbers.ts
index ae038fc..9466f83 100644
--- a/src/arts/fmts/nums/engine-numbers.ts
+++ b/src/arts/fmts/nums/engine-numbers.ts
@@ -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();
-
-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 {
- const numerals = getCachedFormat(locale, { useGrouping: false }).format(9876543210);
- const map = new Map();
- 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({
+ initial: options.decimalSeparator,
+ auto: AUTO_VALUE
+ });
+ const groupSeparator = createAutoState({
+ initial: options.groupSeparator,
+ auto: AUTO_VALUE
+ });
+ const grouping = createAutoState({
+ 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();
}
};
}
diff --git a/src/arts/fmts/nums/number-format.ts b/src/arts/fmts/nums/number-format.ts
new file mode 100644
index 0000000..a402a60
--- /dev/null
+++ b/src/arts/fmts/nums/number-format.ts
@@ -0,0 +1,36 @@
+import type { NumbersFormatOptions } from './types';
+
+const formatCache = new Map();
+
+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 ?? '';
+}
diff --git a/src/arts/fmts/nums/number-parse.ts b/src/arts/fmts/nums/number-parse.ts
new file mode 100644
index 0000000..df22c81
--- /dev/null
+++ b/src/arts/fmts/nums/number-parse.ts
@@ -0,0 +1,45 @@
+import { getCachedNumberFormat } from './number-format';
+
+const numeralMapCache = new Map>();
+
+export interface ParseLocaleNumberOptions {
+ locale: string;
+ decimalSeparator: string;
+ groupSeparator: string;
+}
+
+function buildNumeralMap(locale: string): Map {
+ const cached = numeralMapCache.get(locale);
+ if (cached !== undefined) return cached;
+
+ const numerals = getCachedNumberFormat(locale, { useGrouping: false }).format(9876543210);
+ const map = new Map();
+ 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;
+}
diff --git a/src/arts/fmts/nums/test/active-numbers.test.ts b/src/arts/fmts/nums/test/active-numbers.test.ts
new file mode 100644
index 0000000..fc07eaa
--- /dev/null
+++ b/src/arts/fmts/nums/test/active-numbers.test.ts
@@ -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();
+ });
+});
diff --git a/src/arts/fmts/test/auto-state.test.ts b/src/arts/fmts/test/auto-state.test.ts
new file mode 100644
index 0000000..3da66aa
--- /dev/null
+++ b/src/arts/fmts/test/auto-state.test.ts
@@ -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({
+ 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');
+ });
+});
diff --git a/src/arts/fmts/test/formats.test.ts b/src/arts/fmts/test/formats.test.ts
index 5727be0..4b3aee6 100644
--- a/src/arts/fmts/test/formats.test.ts
+++ b/src/arts/fmts/test/formats.test.ts
@@ -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();
+ });
+});
diff --git a/src/arts/fmts/test/locale-state.test.ts b/src/arts/fmts/test/locale-state.test.ts
new file mode 100644
index 0000000..03d9476
--- /dev/null
+++ b/src/arts/fmts/test/locale-state.test.ts
@@ -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');
+ });
+});
diff --git a/src/arts/fmts/unts/active-units.svelte.ts b/src/arts/fmts/unts/active-units.svelte.ts
index 058c4f3..dc9348a 100644
--- a/src/arts/fmts/unts/active-units.svelte.ts
+++ b/src/arts/fmts/unts/active-units.svelte.ts
@@ -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
};
}
diff --git a/src/arts/fmts/unts/conversions.ts b/src/arts/fmts/unts/conversions.ts
index d9224f5..525fc8f 100644
--- a/src/arts/fmts/unts/conversions.ts
+++ b/src/arts/fmts/unts/conversions.ts
@@ -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') {
diff --git a/src/arts/fmts/unts/defaults.ts b/src/arts/fmts/unts/defaults.ts
index 454c1df..93b421f 100644
--- a/src/arts/fmts/unts/defaults.ts
+++ b/src/arts/fmts/unts/defaults.ts
@@ -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 {
diff --git a/src/arts/fmts/unts/engine-units.ts b/src/arts/fmts/unts/engine-units.ts
index be4bb9d..b64e0ca 100644
--- a/src/arts/fmts/unts/engine-units.ts
+++ b/src/arts/fmts/unts/engine-units.ts
@@ -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({
+ 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;
}
diff --git a/src/arts/fmts/unts/errors.ts b/src/arts/fmts/unts/errors.ts
index daf08fb..87efeff 100644
--- a/src/arts/fmts/unts/errors.ts
+++ b/src/arts/fmts/unts/errors.ts
@@ -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;
+}
diff --git a/src/arts/fmts/unts/index.ts b/src/arts/fmts/unts/index.ts
index 77b24b8..984c16e 100644
--- a/src/arts/fmts/unts/index.ts
+++ b/src/arts/fmts/unts/index.ts
@@ -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 {
diff --git a/src/arts/fmts/unts/test/active-units.test.ts b/src/arts/fmts/unts/test/active-units.test.ts
new file mode 100644
index 0000000..16e5844
--- /dev/null
+++ b/src/arts/fmts/unts/test/active-units.test.ts
@@ -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();
+ });
+});
diff --git a/src/arts/fmts/unts/test/engine-units.test.ts b/src/arts/fmts/unts/test/engine-units.test.ts
index d3d1d1c..95712d7 100644
--- a/src/arts/fmts/unts/test/engine-units.test.ts
+++ b/src/arts/fmts/unts/test/engine-units.test.ts
@@ -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);
+ }
+ });
});
diff --git a/src/arts/http/consts.ts b/src/arts/http/consts.ts
index f650781..02d8ddf 100644
--- a/src/arts/http/consts.ts
+++ b/src/arts/http/consts.ts
@@ -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}`;
}
diff --git a/src/arts/http/diagnostics.ts b/src/arts/http/diagnostics.ts
new file mode 100644
index 0000000..a2fd82f
--- /dev/null
+++ b/src/arts/http/diagnostics.ts
@@ -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;
+export type HttpDiagnostics = Diagnostics;
+
+const HTTP_DIAGNOSTIC_LOGS: DiagnosticCatalog = {
+ [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
+ });
+}
diff --git a/src/arts/http/engine-http.ts b/src/arts/http/engine-http.ts
index e6e1e01..e37885d 100644
--- a/src/arts/http/engine-http.ts
+++ b/src/arts/http/engine-http.ts
@@ -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;
- 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(
- a: ReadonlyArray | undefined,
- b: ReadonlyArray | undefined
-): ReadonlyArray | 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 {
- 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(
- defaults: ResolvedDefaults,
+ defaults: ResolvedHttpDefaults,
method: HttpMethod,
url: string,
init: (HttpGetInit | HttpBodyRequestInit) | undefined
): HttpResultPromise> {
- 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(
// 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 },
- error
- });
- }
+ emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.BODY_SCHEMA_REJECTED, {
+ method,
+ url,
+ issueCount: validated.issues.length,
+ error
+ });
throw error;
}
}
@@ -288,11 +187,11 @@ async function execute(
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,12 +206,12 @@ async function execute(
} catch (err) {
response = undefined;
lastError = classifyFetchError(err, signal);
- if (logger !== undefined) {
- logger.warn(LOGGER_CATEGORY, networkErrorLogMessage(method, ctx.url), {
- context: { attempt },
- error: lastError instanceof Error ? lastError : new Error(String(lastError))
- });
- }
+ 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.
@@ -324,13 +223,14 @@ async function execute(
});
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(
// 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(
// 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: {
- url: fullUrl,
- status: finalResponse.status,
- statusText: finalResponse.statusText
- }
- }
- );
- }
+ emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.HTTP_STATUS, {
+ method,
+ url: fullUrl,
+ status: finalResponse.status,
+ statusText: finalResponse.statusText
+ });
return buildHttpFailure(finalResponse, method);
}
@@ -425,99 +320,6 @@ async function execute(
return { ok: false, kind: HTTP_RESULT_KIND_NETWORK, error: lastError } as HttpResult>;
}
-// ============================================================================
-// HOOK RUNNERS
-// ============================================================================
-
-async function runBeforeRequest(
- hooks: ReadonlyArray,
- ctx: HookContext
-): Promise {
- for (const hook of hooks) {
- const result = await hook(ctx);
- if (result instanceof Response) return result;
- }
- return undefined;
-}
-
-async function runBeforeRetry(
- hooks: ReadonlyArray,
- ctx: HookContext & { error: unknown; retryDelay: number }
-): Promise {
- for (const hook of hooks) {
- await hook(ctx);
- }
-}
-
-async function runAfterResponse(
- hooks: ReadonlyArray,
- ctx: HookContext,
- response: Response
-): Promise {
- 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,
- ctx: HookContext & { response?: Response },
- error: unknown
-): Promise {
- 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(
- response: Response,
- method: HttpMethod,
- schema: S | undefined
-): Promise>> {
- if (schema === undefined) {
- return { ok: true, value: undefined as Out, 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, response };
-}
-
-async function buildHttpFailure(
- response: Response,
- method: HttpMethod
-): Promise>> {
- 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(
- schema: StandardSchemaV1,
- value: unknown
-): StandardSchemaV1.Result | Promise> {
- const result = schema['~standard'].validate(value);
- return isPromiseLike(result) ? result : result;
-}
diff --git a/src/arts/http/engine-options.ts b/src/arts/http/engine-options.ts
new file mode 100644
index 0000000..acdc050
--- /dev/null
+++ b/src/arts/http/engine-options.ts
@@ -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;
+ 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 {
+ 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(
+ a: ReadonlyArray | undefined,
+ b: ReadonlyArray | undefined
+): ReadonlyArray | undefined {
+ if (a === undefined) return b;
+ if (b === undefined) return a;
+ return [...a, ...b];
+}
diff --git a/src/arts/http/hooks.ts b/src/arts/http/hooks.ts
new file mode 100644
index 0000000..13d7026
--- /dev/null
+++ b/src/arts/http/hooks.ts
@@ -0,0 +1,55 @@
+import type {
+ AfterResponseHook,
+ BeforeErrorHook,
+ BeforeRequestHook,
+ BeforeRetryHook,
+ HookContext
+} from './types.ts';
+
+export async function runBeforeRequest(
+ hooks: ReadonlyArray,
+ ctx: HookContext
+): Promise {
+ for (const hook of hooks) {
+ const result = await hook(ctx);
+ if (result instanceof Response) return result;
+ }
+ return undefined;
+}
+
+export async function runBeforeRetry(
+ hooks: ReadonlyArray,
+ ctx: HookContext & { error: unknown; retryDelay: number }
+): Promise {
+ for (const hook of hooks) {
+ await hook(ctx);
+ }
+}
+
+export async function runAfterResponse(
+ hooks: ReadonlyArray,
+ ctx: HookContext,
+ response: Response
+): Promise {
+ 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,
+ ctx: HookContext & { response?: Response },
+ error: unknown
+): Promise {
+ 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;
+}
diff --git a/src/arts/http/index.ts b/src/arts/http/index.ts
index 85403b5..7e5c43e 100644
--- a/src/arts/http/index.ts
+++ b/src/arts/http/index.ts
@@ -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';
diff --git a/src/arts/http/results.ts b/src/arts/http/results.ts
new file mode 100644
index 0000000..24f44b9
--- /dev/null
+++ b/src/arts/http/results.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(
+ response: Response,
+ method: HttpMethod,
+ schema: S | undefined
+): Promise>> {
+ if (schema === undefined) {
+ return { ok: true, value: undefined as Out, 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, response };
+}
+
+export async function buildHttpFailure(
+ response: Response,
+ method: HttpMethod
+): Promise>> {
+ 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(
+ schema: StandardSchemaV1,
+ value: unknown
+): StandardSchemaV1.Result | Promise> {
+ const result = schema['~standard'].validate(value);
+ return isPromiseLike(result) ? result : result;
+}
diff --git a/src/arts/http/types.ts b/src/arts/http/types.ts
index fd91c2a..e4b5197 100644
--- a/src/arts/http/types.ts
+++ b/src/arts/http/types.ts
@@ -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;
}
/**
diff --git a/src/arts/lang/README.md b/src/arts/lang/README.md
index e8717a1..b9e7822 100644
--- a/src/arts/lang/README.md
+++ b/src/arts/lang/README.md
@@ -427,7 +427,7 @@ In addition to the above (with `locale` made optional on `t` / `ts`):
| `PluralForms` | `{ other: string } & Partial>` |
| `EngineLang` | Pure engine public interface |
| `ActiveLang` | 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` |
---
diff --git a/src/arts/lang/active-lang.svelte.ts b/src/arts/lang/active-lang.svelte.ts
index 6dab28e..7a50b9a 100644
--- a/src/arts/lang/active-lang.svelte.ts
+++ b/src/arts/lang/active-lang.svelte.ts
@@ -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(
return wrapEngine(childEngine, _locale, onLocaleChange);
},
- setLogger(logger: LangLogger): void {
+ setLogger(logger: Logger): void {
engine.setLogger(logger);
},
diff --git a/src/arts/lang/consts.ts b/src/arts/lang/consts.ts
index bc8663f..4cce35d 100644
--- a/src/arts/lang/consts.ts
+++ b/src/arts/lang/consts.ts
@@ -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;
diff --git a/src/arts/lang/diagnostics.ts b/src/arts/lang/diagnostics.ts
new file mode 100644
index 0000000..94554e3
--- /dev/null
+++ b/src/arts/lang/diagnostics.ts
@@ -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;
+export type LangDiagnostics = Diagnostics;
+
+const LANG_DIAGNOSTIC_LOGS: DiagnosticCatalog = {
+ [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
+ });
+}
diff --git a/src/arts/lang/engine-lang.ts b/src/arts/lang/engine-lang.ts
index 190e41f..b2ec320 100644
--- a/src/arts/lang/engine-lang.ts
+++ b/src/arts/lang/engine-lang.ts
@@ -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(
schema: S,
defaultLocale: SupportedLocale = 'es',
fallbackChain?: SupportedLocale[],
- initialLogger?: { logger: LangLogger; loggerSet: boolean }
+ initialLogger?: { logger: Logger; loggerSet: boolean }
): EngineLang {
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(
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(
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(
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
const seen = visited ?? new Set();
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(
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(
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(
}
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(
[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(
getFallbackChain
};
}
-
-const consoleLogger: LangLogger = {
- warn: (_category: string, message: string) => DEV && console.warn(message),
- error: (_category: string, message: string) => DEV && console.error(message)
-};
diff --git a/src/arts/lang/errors.ts b/src/arts/lang/errors.ts
index d04c820..6c28e40 100644
--- a/src/arts/lang/errors.ts
+++ b/src/arts/lang/errors.ts
@@ -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;
diff --git a/src/arts/lang/helpers.ts b/src/arts/lang/helpers.ts
index 3349f9d..0afba26 100644
--- a/src/arts/lang/helpers.ts
+++ b/src/arts/lang/helpers.ts
@@ -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(
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;
}
diff --git a/src/arts/lang/index.ts b/src/arts/lang/index.ts
index 4757053..a1ca177 100644
--- a/src/arts/lang/index.ts
+++ b/src/arts/lang/index.ts
@@ -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';
diff --git a/src/arts/lang/json.ts b/src/arts/lang/json.ts
index f030dca..443c71c 100644
--- a/src/arts/lang/json.ts
+++ b/src/arts/lang/json.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)) {
diff --git a/src/arts/lang/mono-lang.svelte.ts b/src/arts/lang/mono-lang.svelte.ts
index 9ba6de9..9c1ce8f 100644
--- a/src/arts/lang/mono-lang.svelte.ts
+++ b/src/arts/lang/mono-lang.svelte.ts
@@ -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 }
- ) => 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 {
const initialLocale = options.initialLocale ?? DEFAULT_MONO_LOCALE;
- let logger = options.logger;
+ let diagnostics = createLangDiagnostics(options.logger);
+ let loggerSet = options.logger !== undefined;
let locale = $state(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: 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,
+ 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,
diff --git a/src/arts/lang/test/lang.test.ts b/src/arts/lang/test/lang.test.ts
index aaab4f4..337fa59 100644
--- a/src/arts/lang/test/lang.test.ts
+++ b/src/arts/lang/test/lang.test.ts
@@ -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 {
+ 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.
diff --git a/src/arts/lang/types.ts b/src/arts/lang/types.ts
index 18e353f..98d5e3f 100644
--- a/src/arts/lang/types.ts
+++ b/src/arts/lang/types.ts
@@ -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 = {
namespace: NS,
module: M
) => EngineLang;
- setLogger: (logger: LangLogger) => void;
+ setLogger: (logger: Logger) => void;
getDefaultLocale: () => SupportedLocale;
getFallbackChain: () => SupportedLocale[] | undefined;
};
@@ -131,13 +132,8 @@ export type ActiveLang