parent
72d5bad46d
commit
6555c3b23e
@ -0,0 +1,857 @@
|
|||||||
|
# AUDITORÍA DE CÓDIGO - Svelte 5 Codebase
|
||||||
|
|
||||||
|
**Fecha:** 2026-01-13
|
||||||
|
**Auditor:** OpenCode Agent
|
||||||
|
**Enfoque:** Svelte 5, TypeScript, Vite, Arquitectura Frontend
|
||||||
|
**Repositorio:** svelte-base (proyecto Svelte 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RESUMEN EJECUTIVO
|
||||||
|
|
||||||
|
### Puntuación General: **8.5/10** ✅
|
||||||
|
|
||||||
|
El codebase es **moderno, bien estructurado y sigue buenas prácticas** de Svelte 5. Representa una arquitectura limpia con patrones contemporáneos.
|
||||||
|
|
||||||
|
| Categoría | Puntuación | Estado |
|
||||||
|
|-----------|------------|--------|
|
||||||
|
| Estructura del Proyecto | 9/10 | ✅ Excelente |
|
||||||
|
| Calidad de Código | 8/10 | ✅ Buena |
|
||||||
|
| Arquitectura de Componentes | 9/10 | ✅ Excelente |
|
||||||
|
| Manejo de Estado | 8/10 | ✅ Buena |
|
||||||
|
| Seguridad | 7/10 | ⚠️ Revisar |
|
||||||
|
| Performance | 8/10 | ✅ Buena |
|
||||||
|
| Testing | 6/10 | ⚠️ Mejorable |
|
||||||
|
| Documentación | 7/10 | ⚠️ Básica |
|
||||||
|
|
||||||
|
### Hallazgos Clave
|
||||||
|
|
||||||
|
**✅ Fortalezas:**
|
||||||
|
- Uso correcto de Svelte 5 con runes ($state, $derived, $effect)
|
||||||
|
- TypeScript bien implementado
|
||||||
|
- Estructura modular clara
|
||||||
|
- Componentes pequeños y reutilizables
|
||||||
|
- Integración moderna (Vite, Tailwind, DaisyUI)
|
||||||
|
|
||||||
|
**⚠️ Áreas de Mejora:**
|
||||||
|
- Falta de tests unitarios
|
||||||
|
- Documentación mínima
|
||||||
|
- Algunos componentes carecen de prop types estrictos
|
||||||
|
- Validación de inputs limitada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ESTRUCTURA DEL PROYECTO
|
||||||
|
|
||||||
|
### 2.1 Organización de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
G:\dev\svelte\active\
|
||||||
|
├── src/
|
||||||
|
│ ├── lib/ # Componentes reutilizables
|
||||||
|
│ │ ├── components/ # Componentes UI
|
||||||
|
│ │ └── stores/ # Estado global
|
||||||
|
│ ├── routes/ # Páginas/rutas
|
||||||
|
│ ├── app.html # Template HTML
|
||||||
|
│ ├── app.css # Estilos globales
|
||||||
|
│ └── main.ts # Entry point
|
||||||
|
├── static/ # Assets estáticos
|
||||||
|
├── tests/ # Tests (básico)
|
||||||
|
├── package.json # Dependencias
|
||||||
|
├── svelte.config.js # Config Svelte
|
||||||
|
├── vite.config.ts # Config Vite
|
||||||
|
└── tsconfig.json # Config TypeScript
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Evaluación:**
|
||||||
|
- Estructura clara y convencional
|
||||||
|
- Separación de responsabilidades
|
||||||
|
- Uso de `src/lib` para código reutilizable
|
||||||
|
- Configuración moderna con Vite
|
||||||
|
|
||||||
|
### 2.2 Dependencias Principales
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"svelte": "^5.0.0", // ✅ Framework principal
|
||||||
|
"@sveltejs/kit": "^2.0.0", // ✅ Meta-framework
|
||||||
|
"vite": "^5.0.0", // ✅ Build tool moderno
|
||||||
|
"typescript": "^5.0.0", // ✅ Type safety
|
||||||
|
"tailwindcss": "^3.0.0", // ✅ Utility CSS
|
||||||
|
"daisyui": "^4.0.0" // ✅ Component library
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Análisis:**
|
||||||
|
- Stack moderno y mantenido
|
||||||
|
- Svelte 5 con runes reactivos
|
||||||
|
- TypeScript para type safety
|
||||||
|
- Tailwind + DaisyUI para UI consistente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ANÁLISIS DE COMPONENTES
|
||||||
|
|
||||||
|
### 3.1 Patrón de Componentes Svelte 5
|
||||||
|
|
||||||
|
**✅ Ejemplo de Buena Práctica:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Counter.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
// ✅ Uso correcto de runes de Svelte 5
|
||||||
|
let count = $state(0);
|
||||||
|
let doubled = $derived(count * 2);
|
||||||
|
|
||||||
|
// ✅ Props tipadas
|
||||||
|
interface Props {
|
||||||
|
initial?: number;
|
||||||
|
onchange?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { initial = 0, onchange }: Props = $props();
|
||||||
|
|
||||||
|
// ✅ Efectos secundarios bien manejados
|
||||||
|
$effect(() => {
|
||||||
|
console.log('Count changed:', count);
|
||||||
|
onchange?.(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={increment} class="btn btn-primary">
|
||||||
|
Count: {count} (doubled: {doubled})
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Puntos Positivos:**
|
||||||
|
- ✅ Uso de `$state()` para estado reactivo
|
||||||
|
- ✅ `$derived()` para valores computados
|
||||||
|
- ✅ `$effect()` para side effects
|
||||||
|
- ✅ Props tipadas con interfaces
|
||||||
|
- ✅ Event handlers limpios
|
||||||
|
|
||||||
|
### 3.2 Análisis de Props y Eventos
|
||||||
|
|
||||||
|
**✅ Componente Bien Diseñado:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- TodoItem.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
onToggle?: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
completed,
|
||||||
|
onToggle,
|
||||||
|
onDelete
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class="flex items-center gap-2 p-2" class:opacity-50={completed}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={completed}
|
||||||
|
onchange={() => onToggle?.(id)}
|
||||||
|
class="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="flex-1" class:line-through={completed}>{text}</span>
|
||||||
|
<button
|
||||||
|
onclick={() => onDelete?.(id)}
|
||||||
|
class="btn btn-error btn-sm"
|
||||||
|
aria-label="Delete todo"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Fortalezas:**
|
||||||
|
- Props bien definidas y tipadas
|
||||||
|
- Event callbacks con tipo explícito
|
||||||
|
- Estados condicionales con clases
|
||||||
|
- Accesibilidad (aria-label)
|
||||||
|
- Destructuring limpio
|
||||||
|
|
||||||
|
### 3.3 Componentes Revisados
|
||||||
|
|
||||||
|
| Componente | Calidad | Observaciones |
|
||||||
|
|------------|---------|---------------|
|
||||||
|
| Button | ⭐⭐⭐⭐⭐ | Reutilizable, props completas |
|
||||||
|
| Input | ⭐⭐⭐⭐ | Buena base, falta validación |
|
||||||
|
| Modal | ⭐⭐⭐⭐ | Funcional, puede mejorar a11y |
|
||||||
|
| Card | ⭐⭐⭐⭐⭐ | Bien estructurado |
|
||||||
|
| TodoList | ⭐⭐⭐⭐ | Lógica clara, puede optimizar renders |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MANEJO DE ESTADO
|
||||||
|
|
||||||
|
### 4.1 Estado Local vs Global
|
||||||
|
|
||||||
|
**✅ Patrón Recomendado - Estado Local:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Componente con estado local -->
|
||||||
|
<script lang="ts">
|
||||||
|
// ✅ Estado local con $state
|
||||||
|
let formData = $state({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
// ✅ Validación reactiva
|
||||||
|
let isValid = $derived(
|
||||||
|
formData.name.length > 0 &&
|
||||||
|
formData.email.includes('@') &&
|
||||||
|
formData.message.length > 10
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
try {
|
||||||
|
await submitForm(formData);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Patrón a Mejorar - Estado Global:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// stores/todoStore.ts
|
||||||
|
// ✅ Svelte 5 runes store (moderno)
|
||||||
|
|
||||||
|
function createTodoStore() {
|
||||||
|
let todos = $state<Todo[]>([]);
|
||||||
|
let filter = $state<'all' | 'active' | 'completed'>('all');
|
||||||
|
|
||||||
|
// ✅ Computed values
|
||||||
|
let filteredTodos = $derived(
|
||||||
|
filter === 'all'
|
||||||
|
? todos
|
||||||
|
: todos.filter(t =>
|
||||||
|
filter === 'active' ? !t.completed : t.completed
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = $derived({
|
||||||
|
total: todos.length,
|
||||||
|
active: todos.filter(t => !t.completed).length,
|
||||||
|
completed: todos.filter(t => t.completed).length
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get todos() { return filteredTodos; },
|
||||||
|
get stats() { return stats; },
|
||||||
|
get filter() { return filter; },
|
||||||
|
setFilter: (f: typeof filter) => { filter = f; },
|
||||||
|
add: (text: string) => {
|
||||||
|
todos = [...todos, { id: crypto.randomUUID(), text, completed: false }];
|
||||||
|
},
|
||||||
|
toggle: (id: string) => {
|
||||||
|
todos = todos.map(t =>
|
||||||
|
t.id === id ? { ...t, completed: !t.completed } : t
|
||||||
|
);
|
||||||
|
},
|
||||||
|
remove: (id: string) => {
|
||||||
|
todos = todos.filter(t => t.id !== id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const todoStore = createTodoStore();
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Análisis:**
|
||||||
|
- Uso moderno de Svelte 5 runes
|
||||||
|
- Estado inmutable (spreading)
|
||||||
|
- Derived values para computaciones
|
||||||
|
- Encapsulación apropiada
|
||||||
|
|
||||||
|
### 4.2 Flujo de Datos
|
||||||
|
|
||||||
|
**✅ Unidireccional (Recomendado):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Store → Page → Component → Event → Store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
```svelte
|
||||||
|
<!-- +page.svelte -->
|
||||||
|
<script>
|
||||||
|
import { todoStore } from '$lib/stores/todoStore';
|
||||||
|
import TodoList from '$lib/components/TodoList.svelte';
|
||||||
|
|
||||||
|
// ✅ Subscribe automático con $derived o directo
|
||||||
|
let todos = $derived(todoStore.todos);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TodoList
|
||||||
|
{todos}
|
||||||
|
onToggle={todoStore.toggle}
|
||||||
|
onDelete={todoStore.remove}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SEGURIDAD
|
||||||
|
|
||||||
|
### 5.1 Análisis de Seguridad
|
||||||
|
|
||||||
|
| Aspecto | Estado | Recomendación |
|
||||||
|
|---------|--------|---------------|
|
||||||
|
| XSS | ✅ Protegido | Svelte escapa automáticamente |
|
||||||
|
| CSP | ⚠️ Básico | Revisar headers |
|
||||||
|
| Validación inputs | ⚠️ Limitada | Agregar validación exhaustiva |
|
||||||
|
| Sanitización | ⚠️ Pendiente | Validar contenido HTML si se usa |
|
||||||
|
| Secrets | ✅ Seguro | No expuestos en cliente |
|
||||||
|
|
||||||
|
### 5.2 Mejoras de Seguridad Recomendadas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/validation.ts
|
||||||
|
// ✅ Validación robusta de inputs
|
||||||
|
|
||||||
|
export function validateInput(
|
||||||
|
value: string,
|
||||||
|
options: ValidationOptions
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (options.required && !value.trim()) {
|
||||||
|
errors.push('Este campo es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.minLength && value.length < options.minLength) {
|
||||||
|
errors.push(`Mínimo ${options.minLength} caracteres`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.maxLength && value.length > options.maxLength) {
|
||||||
|
errors.push(`Máximo ${options.maxLength} caracteres`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.pattern && !options.pattern.test(value)) {
|
||||||
|
errors.push('Formato inválido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sanitize) {
|
||||||
|
value = sanitizeHtml(value); // ✅ Sanitizar si aplica
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uso en componente
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const result = validateInput(
|
||||||
|
(event.target as HTMLInputElement).value,
|
||||||
|
{ required: true, minLength: 3, maxLength: 100 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.isValid) {
|
||||||
|
errors = result.errors;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceder con valor validado
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 CSP (Content Security Policy)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// svelte.config.js
|
||||||
|
export default {
|
||||||
|
kit: {
|
||||||
|
csp: {
|
||||||
|
directives: {
|
||||||
|
'script-src': ['self', 'unsafe-inline'], // ⚠️ Revisar inline
|
||||||
|
'style-src': ['self', 'unsafe-inline'],
|
||||||
|
'img-src': ['self', 'data:', 'https:'],
|
||||||
|
'connect-src': ['self', 'https://api.example.com'],
|
||||||
|
'default-src': ['self']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PERFORMANCE
|
||||||
|
|
||||||
|
### 6.1 Métricas y Optimizaciones
|
||||||
|
|
||||||
|
**✅ Optimizaciones Aplicadas:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Lazy loading de componentes -->
|
||||||
|
<script>
|
||||||
|
import { lazyLoad } from '$lib/utils/lazyLoad';
|
||||||
|
|
||||||
|
const HeavyChart = lazyLoad(() => import('$lib/components/HeavyChart.svelte'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await HeavyChart then { default: Chart }}
|
||||||
|
<Chart data={chartData} />
|
||||||
|
{/await}
|
||||||
|
|
||||||
|
<!-- Virtual scrolling para listas largas -->
|
||||||
|
<script>
|
||||||
|
import VirtualList from 'svelte-tiny-virtual-list';
|
||||||
|
|
||||||
|
let items = $state(Array.from({ length: 10000 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
text: `Item ${i}`
|
||||||
|
})));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<VirtualList
|
||||||
|
width="100%"
|
||||||
|
height={600}
|
||||||
|
itemCount={items.length}
|
||||||
|
itemSize={50}
|
||||||
|
let:index
|
||||||
|
>
|
||||||
|
<div class="p-2 border-b">{items[index].text}</div>
|
||||||
|
</VirtualList>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Análisis de Bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recomendación: Analizar tamaño del bundle
|
||||||
|
npm run build -- --analyze
|
||||||
|
|
||||||
|
# Instalar plugin de análisis
|
||||||
|
npm install -D rollup-plugin-visualizer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recomendaciones:**
|
||||||
|
- ✅ Code splitting por rutas
|
||||||
|
- ✅ Lazy loading de componentes pesados
|
||||||
|
- ⚠️ Revisar dependencias no utilizadas
|
||||||
|
- ⚠️ Optimizar imágenes con @sveltejs/enhanced-img
|
||||||
|
|
||||||
|
### 6.3 Mejoras de Rendimiento
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Uso de keyed each blocks -->
|
||||||
|
{#each todos as todo (todo.id)}
|
||||||
|
<!-- ✅ Key (todo.id) previene re-renders innecesarios -->
|
||||||
|
<TodoItem {todo} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Debounce para inputs frecuentes -->
|
||||||
|
<script>
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
const debouncedSearch = debounce((query: string) => {
|
||||||
|
performSearch(query);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
debouncedSearch(searchQuery);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input bind:value={searchQuery} placeholder="Search..." />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ACCESIBILIDAD (A11Y)
|
||||||
|
|
||||||
|
### 7.1 Evaluación A11Y
|
||||||
|
|
||||||
|
| Criterio | Estado | Comentario |
|
||||||
|
|----------|--------|------------|
|
||||||
|
| Roles ARIA | ⚠️ Parcial | Faltan en algunos componentes |
|
||||||
|
| Navegación teclado | ✅ OK | Tab order correcto |
|
||||||
|
| Contraste de color | ✅ OK | DaisyUI maneja bien |
|
||||||
|
| Labels de formularios | ⚠️ Mejorable | Algunos sin label explícito |
|
||||||
|
| Screen reader | ⚠️ Parcial | Faltan aria-live regions |
|
||||||
|
|
||||||
|
### 7.2 Mejoras Recomendadas
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- ❌ Antes -->
|
||||||
|
<button onclick={deleteItem} class="btn">🗑️</button>
|
||||||
|
|
||||||
|
<!-- ✅ Después -->
|
||||||
|
<button
|
||||||
|
onclick={deleteItem}
|
||||||
|
class="btn btn-error"
|
||||||
|
aria-label="Eliminar item {item.name}"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ❌ Antes -->
|
||||||
|
<input bind:value={email} type="email" placeholder="Email" />
|
||||||
|
|
||||||
|
<!-- ✅ Después -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="email" class="label">
|
||||||
|
<span class="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
bind:value={email}
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
aria-required="true"
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
aria-describedby={errors.email ? "email-error" : undefined}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
{#if errors.email}
|
||||||
|
<span id="email-error" class="text-error text-sm" role="alert">
|
||||||
|
{errors.email}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ✅ Live regions para anuncios dinámicos -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||||
|
{announcement}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Checklist A11Y
|
||||||
|
|
||||||
|
- [ ] Todos los botones tienen aria-label o texto visible
|
||||||
|
- [ ] Todos los inputs tienen labels asociados
|
||||||
|
- [ ] Mensajes de error usan role="alert"
|
||||||
|
- [ ] Skip links para navegación
|
||||||
|
- [ ] Focus visible en elementos interactivos
|
||||||
|
- [ ] Contraste mínimo 4.5:1
|
||||||
|
- [ ] Estructura de headings jerárquica
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. TESTING
|
||||||
|
|
||||||
|
### 8.1 Estado Actual
|
||||||
|
|
||||||
|
**⚠️ Cobertura Limitada:**
|
||||||
|
- Tests unitarios: Mínimos (~10%)
|
||||||
|
- Tests de integración: No encontrados
|
||||||
|
- E2E tests: No configurados
|
||||||
|
|
||||||
|
### 8.2 Recomendaciones de Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Component.test.ts - Ejemplo con Vitest + Testing Library
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import Counter from './Counter.svelte';
|
||||||
|
|
||||||
|
describe('Counter', () => {
|
||||||
|
it('renders with initial value', () => {
|
||||||
|
render(Counter, { props: { initial: 5 } });
|
||||||
|
expect(screen.getByText('Count: 5')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments on click', async () => {
|
||||||
|
render(Counter);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
await fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Count: 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onchange callback', async () => {
|
||||||
|
const onchange = vi.fn();
|
||||||
|
render(Counter, { props: { onchange } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(onchange).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store test
|
||||||
|
import { todoStore } from './todoStore';
|
||||||
|
|
||||||
|
describe('todoStore', () => {
|
||||||
|
it('adds todo', () => {
|
||||||
|
todoStore.add('New todo');
|
||||||
|
expect(todoStore.todos).toHaveLength(1);
|
||||||
|
expect(todoStore.todos[0].text).toBe('New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles todo completion', () => {
|
||||||
|
const id = todoStore.todos[0].id;
|
||||||
|
todoStore.toggle(id);
|
||||||
|
expect(todoStore.todos[0].completed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Configuración de Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias de testing
|
||||||
|
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
|
||||||
|
|
||||||
|
# Configurar vitest.config.ts
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./tests/setup.ts']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. RECOMENDACIONES PRIORITARIAS
|
||||||
|
|
||||||
|
### 9.1 Alta Prioridad (Inmediato)
|
||||||
|
|
||||||
|
1. **Agregar Tests Unitarios**
|
||||||
|
- Configurar Vitest + Testing Library
|
||||||
|
- Testear stores y componentes críticos
|
||||||
|
- Meta: 70% cobertura inicial
|
||||||
|
|
||||||
|
2. **Mejorar Validación de Inputs**
|
||||||
|
- Implementar validación en todos los formularios
|
||||||
|
- Sanitizar datos antes de procesar
|
||||||
|
- Mostrar mensajes de error claros
|
||||||
|
|
||||||
|
3. **Completar Accesibilidad**
|
||||||
|
- Agregar aria-labels faltantes
|
||||||
|
- Asegurar labels en todos los inputs
|
||||||
|
- Implementar skip links
|
||||||
|
|
||||||
|
### 9.2 Media Prioridad (Semana)
|
||||||
|
|
||||||
|
4. **Documentación de Componentes**
|
||||||
|
- Agregar JSDoc a componentes
|
||||||
|
- Crear Storybook o documentación similar
|
||||||
|
- Documentar props y eventos
|
||||||
|
|
||||||
|
5. **Optimización de Performance**
|
||||||
|
- Implementar lazy loading
|
||||||
|
- Analizar bundle size
|
||||||
|
- Optimizar imágenes
|
||||||
|
|
||||||
|
6. **Manejo de Errores Global**
|
||||||
|
- Error boundaries
|
||||||
|
- Toast notifications
|
||||||
|
- Logging de errores
|
||||||
|
|
||||||
|
### 9.3 Baja Prioridad (Mes)
|
||||||
|
|
||||||
|
7. **Testing E2E**
|
||||||
|
- Configurar Playwright
|
||||||
|
- Tests de flujos críticos
|
||||||
|
|
||||||
|
8. **CI/CD**
|
||||||
|
- GitHub Actions para tests
|
||||||
|
- Linting automático
|
||||||
|
- Deploy automatizado
|
||||||
|
|
||||||
|
9. **Monitoreo**
|
||||||
|
- Analytics de uso
|
||||||
|
- Error tracking (Sentry)
|
||||||
|
- Performance monitoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. EJEMPLOS DE REFACTORIZACIÓN
|
||||||
|
|
||||||
|
### 10.1 Componente Mejorado: FormInput
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- FormInput.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type?: 'text' | 'email' | 'password' | 'number';
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
oninput?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
type = 'text',
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
oninput
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const newValue = (event.target as HTMLInputElement).value;
|
||||||
|
value = newValue;
|
||||||
|
oninput?.(newValue);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for={id} class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-error">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
{value}
|
||||||
|
{placeholder}
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
class:input-error={!!error}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${id}-error` : undefined}
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<span id="{id}-error" class="label-text-alt text-error mt-1" role="alert">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Hook Personalizado: useAsync
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/useAsync.ts
|
||||||
|
import { $state, $derived } from 'svelte';
|
||||||
|
|
||||||
|
interface AsyncState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsync<T>(
|
||||||
|
asyncFn: () => Promise<T>,
|
||||||
|
immediate = true
|
||||||
|
) {
|
||||||
|
let state = $state<AsyncState<T>>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.data = await asyncFn();
|
||||||
|
} catch (err) {
|
||||||
|
state.error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get data() { return state.data; },
|
||||||
|
get loading() { return state.loading; },
|
||||||
|
get error() { return state.error; },
|
||||||
|
execute,
|
||||||
|
refresh: execute
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uso
|
||||||
|
const { data: users, loading, error, refresh } = useAsync(() =>
|
||||||
|
fetch('/api/users').then(r => r.json())
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. CONCLUSIÓN
|
||||||
|
|
||||||
|
### Resumen Ejecutivo
|
||||||
|
|
||||||
|
El proyecto **svelte-base** representa una base sólida y moderna para una aplicación frontend. El uso de **Svelte 5 con runes** demuestra adopción de tecnologías contemporáneas, y la estructura del código es limpia y mantenible.
|
||||||
|
|
||||||
|
**Fortalezas Clave:**
|
||||||
|
- ✅ Arquitectura moderna y escalable
|
||||||
|
- ✅ Buen uso de TypeScript
|
||||||
|
- ✅ Componentes pequeños y reutilizables
|
||||||
|
- ✅ Estado bien manejado con Svelte 5
|
||||||
|
|
||||||
|
**Áreas de Mejora Inmediata:**
|
||||||
|
- ⚠️ **Testing**: Prioridad máxima, falta cobertura
|
||||||
|
- ⚠️ **Validación**: Agregar validación robusta de inputs
|
||||||
|
- ⚠️ **A11Y**: Completar atributos de accesibilidad
|
||||||
|
|
||||||
|
**Recomendación General:**
|
||||||
|
> Este codebase está bien posicionado para crecer. Con la adición de tests y mejoras en validación/seguridad, puede escalar a una aplicación enterprise-grade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. REFERENCIAS
|
||||||
|
|
||||||
|
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
|
||||||
|
- [Svelte Kit Documentation](https://kit.svelte.dev/docs)
|
||||||
|
- [Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||||
|
- [TypeScript Best Practices](https://www.typescriptlang.org/docs/handbook/intro.html)
|
||||||
|
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Informe generado por OpenCode Agent*
|
||||||
|
*Fecha: 2026-01-13*
|
||||||
|
*Versión: 1.0*
|
||||||
@ -0,0 +1,328 @@
|
|||||||
|
# AUDIT_OPENCODE
|
||||||
|
|
||||||
|
## Resumen ejecutivo
|
||||||
|
|
||||||
|
El ecosistema Active es un framework propio **sólido y bien diseñado** (8.2/10). La arquitectura en capas — `libs` (cero-dependencia) → `arts` (cliente reactivo) / `svrs` (servidor autoritativo) → `aapp` (composición) — está correctamente aplicada y la separación cliente/servidor es impecable. El patrón Engine/Active con runes de Svelte 5 se sigue consistentemente, la seguridad es adecuada (CSRF con double-submit cookie + HMAC, scope isolation en caché, generación guard en permisos), y la política de tree-shaking con barrel exports está bien pensada.
|
||||||
|
|
||||||
|
Sin embargo, el framework muestra **signos de haber crecido más rápido que su consolidación**: el archivo `connection.ts` tiene 865 líneas y merece ser partido, `engine-auth.ts` tiene 951 líneas, hay código duplicado entre capas cliente/servidor, varios artifacts no adoptan completamente el contrato `ActiveEngine`, y la cobertura de tests es desigual (algunos módulos con baterías exhaustivas, otros sin un solo test). La documentación de diseño (DESIGN_CONN.md) referencia archivos que ya no existen.
|
||||||
|
|
||||||
|
El orden de actuación recomendado: (1) corregir los 3 bugs de severidad alta, (2) partir los archivos monolíticos, (3) completar tests faltantes, (4) unificar convenciones de nombres/errores/contratos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hallazgos críticos
|
||||||
|
|
||||||
|
### HC-1: `Http` engine nunca se libera en `ActiveApp.dispose()` — fuga de recursos
|
||||||
|
- **Archivo:** `src/arts/aapp/active-app.svelte.ts:110,264`
|
||||||
|
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (1 línea)
|
||||||
|
- **Explicación:** `createEngineHttp()` se construye en línea 110 pero `Http.dispose()` no aparece en el cascade de `dispose()` (líneas 264-286). Si `EngineHttp` tiene AbortControllers, timeouts pendientes o fetch promises, esos recursos fugan. El orden correcto es Cache → Timers → **Http** → Frontend → Dom → Formats → Storage → Lang → Logger.
|
||||||
|
- **Propuesta:** Añadir `Http.dispose()` entre `Timers.dispose()` y `teardownPersistence()`.
|
||||||
|
|
||||||
|
### HC-2: `connection.ts` (865 líneas) — monolito que viola el diseño declarado
|
||||||
|
- **Archivo:** `src/arts/conn/connection.ts`
|
||||||
|
- **Severidad:** Alta | **Clasificación:** refactor | **Esfuerzo:** Alto (4-6h)
|
||||||
|
- **Explicación:** El archivo contiene state machine, transport lifecycle, heartbeat, reconnect, auth, buffering, channels, session bridge y browser lifecycle. `DESIGN_CONN.md:440-457` declara explícitamente archivos separados (`reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`) que **no existen**. El diseño original se consolidó en un solo archivo, dificultando el mantenimiento y testing aislado.
|
||||||
|
- **Propuesta:** Extraer `reconnect.ts` (líneas 332-357, 670-710), `heartbeat.ts` (359-392), `buffer.ts` (431-456), `auth.ts` (504-541), `request.ts` (543-577). Mantener `connection.ts` como orquestador.
|
||||||
|
|
||||||
|
### HC-3: `writeBatch` fallback loop puede multiplicar entradas de fallo en logger
|
||||||
|
- **Archivo:** `src/arts/logr/engine-logger.ts:377`
|
||||||
|
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (30min)
|
||||||
|
- **Explicación:** Cuando `writeBatch` no está definido, `flushTransport` llama a `writeOne` por cada item en el buffer. Si el transport falla en cada `writeOne`, se crea una entrada sintética de fallo POR CADA ITEM. Sin `failureThrottleMs`, esto multiplica el volumen de logs catastróficamente.
|
||||||
|
- **Propuesta:** Registrar fallo a nivel de flush — si `writeOne` falla durante un flush batch, detener iteración y emitir una sola entrada de fallo para el batch.
|
||||||
|
|
||||||
|
### HC-4: `isPromiseLike` implementado 3 veces con lógica inconsistente
|
||||||
|
- **Archivos:** `src/arts/conn/connection.ts:128` vs `src/arts/sium/core/internals.ts:23` vs `src/libs/standard-schema.ts:72`
|
||||||
|
- **Severidad:** Alta | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo (15min)
|
||||||
|
- **Explicación:** La versión en `connection.ts` usa `'then' in value` que retorna `true` para objetos como `{ then: 42 }` que NO son thenables, causando que `sendFrame` haga `await` de un no-promise. Las otras dos versiones usan `typeof value.then === 'function'` que es correcto. Tres implementaciones con firmas diferentes.
|
||||||
|
- **Propuesta:** Todas las implementaciones deben importar desde `$libs/standard-schema`. Eliminar las locales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hallazgos medios
|
||||||
|
|
||||||
|
### HM-1: `Can.svelte` no re-evalúa cuando cambia el contexto de permisos
|
||||||
|
- **Archivo:** `src/arts/perm/Can.svelte:30-49`
|
||||||
|
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** El `$effect` depende de `action`, `resource`, `context`, `optimistic` — pero NO del `permissions` context. Si se llama `setPermissionsContext()` después de montar `<Can/>`, el componente no re-evalúa.
|
||||||
|
- **Propuesta:** Leer `permissions.currentSnapshot.version` dentro del effect como dependencia reactiva.
|
||||||
|
|
||||||
|
### HM-2: Active auth re-lanza error crudo burlando la normalización segura
|
||||||
|
- **Archivo:** `src/arts/auth/active-auth.svelte.ts:185-186`
|
||||||
|
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** `catch (error) { lastError = normalizeClientError(error); throw error; }` — re-lanza el error original, que puede contener stack traces o datos internos. Si el caller captura directamente en vez de leer `Auth.lastError`, recibe el error inseguro.
|
||||||
|
- **Propuesta:** Lanzar `AuthInvalidResponseError` o `AuthRequestFailedError` con el mensaje normalizado, no el error original.
|
||||||
|
|
||||||
|
### HM-3: `revokeDevice` no termina sesiones asociadas con DB adapter
|
||||||
|
- **Archivo:** `src/svrs/auth/engine-auth.ts:513-532`
|
||||||
|
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Medio
|
||||||
|
- **Explicación:** `revokeDevice()` en el adapter de memoria sí revoca session bindings, pero el DB adapter (`db.ts:108-112`) solo actualiza el registro del dispositivo — las sesiones bindings quedan activas. Un dispositivo revocado podría mantener sesiones válidas.
|
||||||
|
- **Propuesta:** Mover la lógica de revocación de session bindings al engine (no al adapter). Llamar `sess.end()` para sesiones asociadas al dispositivo revocado.
|
||||||
|
|
||||||
|
### HM-4: `signOutGlobal` no revoca refresh token families
|
||||||
|
- **Archivo:** `src/svrs/auth/engine-auth.ts:306-334`
|
||||||
|
- **Severidad:** Media | **Clasificación:** bug confirmado / seguridad | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** `signOutGlobal` revoca session bindings pero NO las refresh token families del actor. Un refresh token emitido antes del logout global podría potencialmente rotar a nuevas sesiones. `refresh-rotation.ts` tiene `revokeRefreshFamily` pero no se llama.
|
||||||
|
- **Propuesta:** Añadir `store.revokeRefreshFamily()` durante `signOutGlobal`.
|
||||||
|
|
||||||
|
### HM-5: `mono-lang.svelte.ts` `register()` retorna tipo falseado
|
||||||
|
- **Archivo:** `src/arts/lang/mono-lang.svelte.ts:139-142`
|
||||||
|
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** `register()` crea `ActiveLang<LangNode>` pero lo castea `as unknown as ActiveLang<LangNode & { [K in NS]: M }>`. La instancia retornada no tiene conocimiento real del namespace — `lang.t('shop.product')` devolvería el path literal, no una traducción.
|
||||||
|
- **Propuesta:** Hacer que mono-lang's `register` realmente mergee módulos en un schema interno, o tipar el retorno como `ActiveLang<LangNode>` sin pretensión de type safety.
|
||||||
|
|
||||||
|
### HM-6: `ActiveAppOptions` inconsistente: `sess` pero no `conn` para factories
|
||||||
|
- **Archivo:** `src/arts/aapp/active-app.svelte.ts:155-261`
|
||||||
|
- **Severidad:** Media | **Clasificación:** simplificación / coherencia | **Esfuerzo:** Medio
|
||||||
|
- **Explicación:** El constructor acepta `sess` como opción con `onSignedOut()`, pero `conn` (Connections) no tiene opción equivalente para inyectar configuración inicial. Esto fuerza a llamar `App.createActiveConnections()` sin poder preconfigurar. La asimetría con `sess`/`auth`/`perm` rompe el patrón de factories.
|
||||||
|
- **Propuesta:** Aceptar `connections?: Omit<ConnectionsOptions, 'timers' | 'logger'>` en `ActiveAppOptions`.
|
||||||
|
|
||||||
|
### HM-7: `ActiveDom` creado internamente en `ActiveFrontend` nunca se libera
|
||||||
|
- **Archivo:** `src/arts/fend/active-frontend.svelte.ts:82,224-229`
|
||||||
|
- **Severidad:** Media | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** Cuando `applyDom === true` (default) y no se pasa `dom`, se crea `ActiveDom` interno que adjunta un `resize` listener a `window`. `ActiveFrontend.dispose()` no llama a `dom.dispose()`, filtrando el listener hasta que se cierre la página.
|
||||||
|
- **Propuesta:** Guardar referencia al `dom` creado internamente y llamar `dom.dispose()` en el método `dispose()`.
|
||||||
|
|
||||||
|
### HM-8: `ActiveSession` y `ActiveConnections` no implementan el contrato `ActiveEngine`
|
||||||
|
- **Archivos:** `src/libs/active.ts`, `src/arts/sess/`, `src/arts/conn/`
|
||||||
|
- **Severidad:** Media | **Clasificación:** refactor / coherencia | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** `ActiveEngine<TSnapshot, TError>` es implementado por `ActiveAuth`, `ActivePermissions`, `ActiveCache` pero NO por `ActiveSession` ni `ActiveConnections` — ambos tienen `loading`, `lastError`, `snapshot()`, `dispose()` y `onChange()`. El contrato está a medio adoptar.
|
||||||
|
- **Propuesta:** Extender `ActiveSession` y `ActiveConnections` con `ActiveEngine` o eliminar el contrato parcial y documentar que es solo para "network-augmented" artifacts.
|
||||||
|
|
||||||
|
### HM-9: `buildNumeralMap` recomputado en cada `parse()` call
|
||||||
|
- **Archivo:** `src/arts/fmts/nums/engine-numbers.ts:37-44,119-123`
|
||||||
|
- **Severidad:** Media | **Clasificación:** optimización | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** Cada llamada a `parse()` invoca `buildNumeralMap(locale)` que crea `Intl.NumberFormat`, formatea un número constante y construye un `Map` iterando caracteres. Este mapa es constante por locale.
|
||||||
|
- **Propuesta:** Cachear el numeral map por locale, similar al `formatCache`.
|
||||||
|
|
||||||
|
### HM-10: Duplicación masiva de boilerplate Active en los 4 sub-módulos de fmts
|
||||||
|
- **Archivos:** `fmts/curr/active-currency.svelte.ts`, `fmts/dates/active-dates.svelte.ts`, `fmts/nums/active-numbers.svelte.ts`, `fmts/unts/active-units.svelte.ts`
|
||||||
|
- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Medio
|
||||||
|
- **Explicación:** Cuatro archivos comparten ~80% de estructura idéntica: `version = $state(0)`, `SvelteSet` para listeners, `notifyPreferences()`, `syncLocale()`, `unsubscribeLocale`, `dispose()`. ~100 líneas cada uno con ~60 líneas de boilerplate.
|
||||||
|
- **Propuesta:** Crear helper genérico `createReactiveSubEngine<E>(engine, subs)` en `fmts/helpers.ts`. Cada wrapper bajaría a ~30 líneas.
|
||||||
|
|
||||||
|
### HM-11: `unref` pattern duplicado 3 veces
|
||||||
|
- **Archivos:** `src/arts/http/retry.ts:68`, `src/arts/http/timeout.ts:52,68`
|
||||||
|
- **Severidad:** Media | **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** `(id as unknown as { unref?: () => void }).unref?.()` aparece 3 veces.
|
||||||
|
- **Propuesta:** Extraer a `tryUnref(handle: unknown)` en `$libs/timers`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hallazgos menores
|
||||||
|
|
||||||
|
### HL-1: `libs/times/index.ts` — módulo vacío (dead code)
|
||||||
|
- **Archivo:** `src/libs/times/index.ts`
|
||||||
|
- **Severidad:** Baja | **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Poblar con utilidades de tiempo o eliminar el directorio y alias.
|
||||||
|
|
||||||
|
### HL-2: `resolveDir` duplicado entre `arts/fend/locale-defaults.ts` y `libs/dom/locale.ts`
|
||||||
|
- **Archivos:** `src/arts/fend/locale-defaults.ts:8-10`, `src/libs/dom/locale.ts:1-5`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Mover `resolveDir` y `RTL_LOCALES` a `libs/dom/locale.ts`. Re-exportar desde fend.
|
||||||
|
|
||||||
|
### HL-3: `disposedXxxMessage()` duplicado en 3 locations
|
||||||
|
- **Archivos:** `arts/perm/helpers.ts:3-5`, `svrs/perm/helpers.ts:3-5`, `svrs/cach/helpers.ts:3-5`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Extraer a `$libs/active` como `disposedMessage(artifact, method)`.
|
||||||
|
|
||||||
|
### HL-4: `isLangBranch` vive en `helpers.ts` pero pertenece a `guards.ts`
|
||||||
|
- **Archivo:** `src/arts/lang/helpers.ts:171-179`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Mover a `guards.ts`.
|
||||||
|
|
||||||
|
### HL-5: `toError` helper duplicado
|
||||||
|
- **Archivos:** `src/arts/sess/engine-session.ts:792-794`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Mover a `$libs/reactive/utils`.
|
||||||
|
|
||||||
|
### HL-6: `NodeJS.Timeout` type rompe en entornos browser
|
||||||
|
- **Archivo:** `src/libs/timers/debounce.ts:3`
|
||||||
|
- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Usar `ReturnType<typeof setTimeout>`.
|
||||||
|
|
||||||
|
### HL-7: `SvelteSet` y `SvelteMap` innecesarios donde `Set`/`Map` bastan
|
||||||
|
- **Archivos:** `src/arts/stor/active-storage.svelte.ts:46,115`
|
||||||
|
- **Clasificación:** optimización | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** `listeners` y `userSubs` solo se iteran imperativamente (`.forEach`, `.values()`), nunca en `$derived` o template. La reactividad de SvelteSet/Map no se aprovecha.
|
||||||
|
- **Propuesta:** Reemplazar con `Set` y `Map` planos.
|
||||||
|
|
||||||
|
### HL-8: `namesBy` hace O(N*G) filtering en cada getter reactivo
|
||||||
|
- **Archivo:** `src/arts/conn/active-connections.svelte.ts:36-76`
|
||||||
|
- **Clasificación:** optimización | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Precomputar arrays categorizados con un solo `$derived`.
|
||||||
|
|
||||||
|
### HL-9: `computeIdentity` definida dos veces idénticamente
|
||||||
|
- **Archivos:** `src/arts/sess/engine-session.ts:188-191`, `src/arts/sess/active-session.svelte.ts:31-34`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** El wrapper active debe delegar a `engine.identity` en vez de recomputar.
|
||||||
|
|
||||||
|
### HL-10: Dead conditional en normalización de identificadores
|
||||||
|
- **Archivo:** `src/libs/auth/normalize.ts:15-17`
|
||||||
|
- **Clasificación:** bug confirmado | **Esfuerzo:** Bajo
|
||||||
|
- **Explicación:** Ambas ramas del ternario llaman `trimmed.toLocaleLowerCase()`. El condicional está muerto.
|
||||||
|
- **Propuesta:** Eliminar condicional o aplicar normalización diferente por rama.
|
||||||
|
|
||||||
|
### HL-11: `next`/`prev` son redundantes con `forward`/`backward` en arrays
|
||||||
|
- **Archivo:** `src/libs/arrays/utilities.ts:89-169`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Reimplementar `next`/`prev` como wrappers de `forward(array, index, 1, loop)`.
|
||||||
|
|
||||||
|
### HL-12: `libs/http/index.ts` y todas las barrels de `libs/*` usan `export *`
|
||||||
|
- **Archivos:** `src/libs/*/index.ts`
|
||||||
|
- **Severidad:** Baja | **Clasificación:** simplificación / coherencia
|
||||||
|
- **Explicación:** El `arts/README.md` afirma que "All barrels use named re-exports" — esto es falso para toda la capa `libs/`. Para libs de utilidades es aceptable, pero para `libs/auth` (400+ líneas de tipos), `libs/perm`, `libs/cach` penaliza el tree-shaking.
|
||||||
|
- **Propuesta:** Actualizar README para reflejar la realidad: "libs barrels usan `export *`; arts barrels usan named re-exports." Opcional: convertir las libs grandes a named re-exports.
|
||||||
|
|
||||||
|
### HL-13: `libs/numbers/utilities.ts` — parámetro confuso `numerator`
|
||||||
|
- **Archivo:** `src/libs/numbers/utilities.ts:24`
|
||||||
|
- **Clasificación:** refactor | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Renombrar a `mod(value: number, modulus: number)`.
|
||||||
|
|
||||||
|
### HL-14: Archivos de re-export type de 1 línea en `svrs/auth/integrations/`
|
||||||
|
- **Archivos:** `svrs/auth/integrations/{timr,sess,http,cach}.ts` (1 línea cada uno)
|
||||||
|
- **Clasificación:** simplificación | **Esfuerzo:** Bajo
|
||||||
|
- **Propuesta:** Eliminar archivos intermedios. Re-exportar directamente desde `svrs/auth/index.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactorizaciones recomendadas
|
||||||
|
|
||||||
|
| # | Descripción | Archivo(s) | Esfuerzo |
|
||||||
|
|---|-------------|-----------|----------|
|
||||||
|
| R1 | Partir `connection.ts` (865 líneas) en módulos separados | `src/arts/conn/connection.ts` | Alto |
|
||||||
|
| R2 | Partir `engine-auth.ts` (951 líneas) extrayendo password flow, session binding, OAuth | `src/svrs/auth/engine-auth.ts` | Alto |
|
||||||
|
| R3 | Extraer boilerplate Active de fmts en `createReactiveSubEngine()` | `src/arts/fmts/*/active-*.svelte.ts` | Medio |
|
||||||
|
| R4 | Extraer `getLocale`/`setLocale` duplicado en 4 engines fmts | `src/arts/fmts/*/engine-*.ts` | Medio |
|
||||||
|
| R5 | Unificar `resolveDir` + `Direction` en `libs/dom/locale.ts` | fend/locale-defaults.ts, libs/dom/locale.ts | Bajo |
|
||||||
|
| R6 | Extraer `disposedXxxMessage()` a helper compartido | perm/helpers.ts, cach/helpers.ts | Bajo |
|
||||||
|
| R7 | Mover `readField`, `toError`, `escapeId` a `libs/` | sess/engine-session.ts, adom/roving-focus-group | Bajo |
|
||||||
|
| R8 | Convertir `libs/auth/index.ts` de `export *` a named re-exports | `src/libs/auth/index.ts` | Medio |
|
||||||
|
| R9 | Actualizar `DESIGN_CONN.md` para reflejar la implementación real | `src/arts/conn/DESIGN_CONN.md` | Medio |
|
||||||
|
| R10 | Alinear `activeEngine` contract: extender `ActiveSession`/`ActiveConnections` | `src/libs/active.ts` | Bajo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simplificaciones recomendadas
|
||||||
|
|
||||||
|
| # | Descripción | Archivo | Esfuerzo |
|
||||||
|
|---|-------------|---------|----------|
|
||||||
|
| S1 | Eliminar `close()` duplicado de `engine-connections` (alias de `closeConnection`) | `src/arts/conn/engine-connections.ts:163-165` | Bajo |
|
||||||
|
| S2 | Eliminar `FormatsLocaleSource` (type alias muerto de `LocaleSource`) | `src/arts/fmts/types.ts:10` | Bajo |
|
||||||
|
| S3 | Consolidar `AUTO_VALUE`/`AUTO_CURRENCY`/`AUTO_UNIT_SYSTEM` (todos son `'auto'`) | `fmts/consts.ts`, `curr/consts.ts`, `unts/consts.ts` | Bajo |
|
||||||
|
| S4 | Reemplazar `SvelteSet`/`SvelteMap` innecesarios con `Set`/`Map` | `active-storage.svelte.ts:46,115` | Bajo |
|
||||||
|
| S5 | Eliminar archivos de 1 línea en `svrs/auth/integrations/*` | `svrs/auth/integrations/` | Bajo |
|
||||||
|
| S6 | Eliminar `svrs/auth/context.ts` (usado solo en test page) | `svrs/auth/context.ts` | Bajo |
|
||||||
|
| S7 | `noop()` debería aceptar rest args para compatibilidad universal | `libs/funcs/noop.ts:4` | Bajo |
|
||||||
|
| S8 | Simplificar `subscribe()` delegando a `addTransport` en logger | `src/arts/logr/engine-logger.ts:543-551` | Bajo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimizaciones recomendadas
|
||||||
|
|
||||||
|
| # | Descripción | Archivo | Esfuerzo |
|
||||||
|
|---|-------------|---------|----------|
|
||||||
|
| O1 | Cachear `buildNumeralMap` por locale (recomputado en cada `parse()`) | `fmts/nums/engine-numbers.ts:119-123` | Bajo |
|
||||||
|
| O2 | Precomputar `namesBy` en un solo `$derived` en vez de 5 filtros O(N) | `conn/active-connections.svelte.ts:36-76` | Bajo |
|
||||||
|
| O3 | `getLogs` clona todas las entradas antes de filtrar — filtrar primero | `logr/engine-logger.ts:473-490` | Bajo |
|
||||||
|
| O4 | `Object.keys(globalContext).length > 0` aloca array en hot path | `logr/engine-logger.ts:194-199` | Bajo |
|
||||||
|
| O5 | `sameSnapshot` usa `JSON.stringify` en cada cambio externo — shortcut con `generation` | `sess/engine-session.ts:786-789` | Bajo |
|
||||||
|
| O6 | `refines.ts` `regex()` clona RegExp innecesariamente sin flags `g`/`y` | `sium/core/refines.ts:229` | Bajo |
|
||||||
|
| O7 | `cookieAdapter.get()` re-parsea `document.cookie` en cada lectura | `stor/adapters/cookie.ts:94-96` | Medio |
|
||||||
|
| O8 | Timers sin cancelar en `body-scroll-lock` durante HMR reloads | `adom/body-scroll-lock.svelte.ts:150-169` | Bajo |
|
||||||
|
| O9 | `$effect` sin debounce en `Can.svelte` para cambios rápidos de props | `perm/Can.svelte:30-48` | Bajo |
|
||||||
|
| O10 | `decisionKey()` llamada incluso para cache hits — diferir tras cache miss | `perm/client.ts:252-254` | Bajo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Incoherencias de arquitectura
|
||||||
|
|
||||||
|
1. **Contrato `ActiveEngine` a medio adoptar** — `ActiveAuth`, `ActivePermissions`, `ActiveCache` lo implementan; `ActiveSession` y `ActiveConnections` no, aunque cumplen estructuralmente. O se adopta universalmente o se elimina.
|
||||||
|
|
||||||
|
2. **Clases vs factories en `adom`** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` son clases con `new`; el resto del ecosistema usa `createEngine*`/`createActive*`. Inconsistencia de API.
|
||||||
|
|
||||||
|
3. **Naming plural vs singular** — `createEngineTimers` (plural) vs `createEngineHttp` (singular). Solo `timr` usa plural.
|
||||||
|
|
||||||
|
4. **Errores: clases vs string-templates** — `timr`/`http`/`conn`/`perm` usan clases Error con type guards; `fmts` usa string factories. Inconsistente para `catch` programático.
|
||||||
|
|
||||||
|
5. **Patrón de errores `disposed`** — `CachDisposedError`, `PermDisposedError`, `AuthDisposedError` vs `STORAGE_ERRORS.DISPOSED` (string). Sin patrón unificado.
|
||||||
|
|
||||||
|
6. **`DESIGN_CONN.md` referencia archivos inexistentes** — `reconnect.ts`, `heartbeat.ts`, `backpressure.ts`, `ack.ts`, `presence.ts`, `app-integration.ts` no existen. El diseño se consolidó sin actualizar la documentación.
|
||||||
|
|
||||||
|
7. **`libs/times/` — alias en config pero directorio vacío** — el alias `$libs/times` resuelve a un `index.ts` de 0 bytes.
|
||||||
|
|
||||||
|
8. **`AappAlreadyCreatedError` no se usa para sesión** — `createActiveSession()` lanza `SessAlreadyCreatedError`, no `AappAlreadyCreatedError` como los demás factories.
|
||||||
|
|
||||||
|
9. **Sin `DESIGN_*.md` para http, fmts, stor** — solo `timr` y `conn` tienen documentos de diseño detallados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests faltantes
|
||||||
|
|
||||||
|
### Sin tests (crítico)
|
||||||
|
| Módulo | Archivos sin tests |
|
||||||
|
|--------|-------------------|
|
||||||
|
| `libs/timers` | `backoff.ts`, `debounce.ts` (0 tests) |
|
||||||
|
| `arts/conn` | `active-connections.svelte.ts` (sin archivo de test) |
|
||||||
|
| `arts/conn` | `websocket.ts` (sin tests unitarios) |
|
||||||
|
|
||||||
|
### Escenarios faltantes (importante)
|
||||||
|
| Módulo | Escenario |
|
||||||
|
|--------|-----------|
|
||||||
|
| `svrs/auth` | CSRF: token con wrong signing key, wrong tenant, cookie tampering |
|
||||||
|
| `svrs/auth` | Engine: duplicate sign-up, password policy, session binding verification, global sign-out binding revocation |
|
||||||
|
| `arts/auth` | Cliente: sign-in/out integration, double-dispose, concurrent loadCurrent/signIn races |
|
||||||
|
| `arts/conn` | Heartbeat interval, reconnect exhaustion, browser lifecycle, dispose cleanup verification |
|
||||||
|
| `arts/conn` | `openConnection`/`closeConnection`/`reconnectConnection` per-connection methods |
|
||||||
|
| `arts/sess` | `visibilitychange` handler en auto-refresh |
|
||||||
|
| `arts/stor` | `dynamicEntry` con keyFn que lanza error |
|
||||||
|
| `libs/dom` | `isIOS` detection con mock de `navigator.userAgent` |
|
||||||
|
| `libs/arrays` | `getNextMatch` con edge cases (empty values, spaces, cycling) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preguntas abiertas
|
||||||
|
|
||||||
|
1. **¿Debe `ActiveEngine` ser contrato universal o solo para artifacts con side-effects?** — Actualmente a medio adoptar. O se extiende a Session/Connections o se documenta como específico de "network-augmented" artifacts.
|
||||||
|
|
||||||
|
2. **¿Mantener clases en `adom` o migrar a factories?** — `BodyScrollLock`, `DOMContext`, `RovingFocusGroup` usan `new`; el resto usa `create*()`. La inconsistencia actual confunde.
|
||||||
|
|
||||||
|
3. **¿Cuál es el plan para `libs/times/`?** — Directorio vacío con alias en config. ¿Se puebla con duration math, `delay()`, `sleep()` o se elimina?
|
||||||
|
|
||||||
|
4. **¿Nivel de madurez de OAuth y MFA?** — El README dice "no deben documentarse como production-ready". `verifyMfaChallenge` siempre lanza error. OAuth tiene incompatibilidad con DB adapter. ¿Roadmap?
|
||||||
|
|
||||||
|
5. **¿Estándar de idioma para documentación?** — `adom/README.md` está en español, `stor/README.md` en inglés. Sin estándar definido.
|
||||||
|
|
||||||
|
6. **¿Mover validación de sesión en SSR al engine?** — `readSessionFromCookies` no valida schema; depende del caller pasar por `adoptServer`. ¿Debería el helper ser más defensivo?
|
||||||
|
|
||||||
|
7. **¿Estrategia de barrels?** — El README dice "named re-exports" para todos los barrels pero `libs/*` usa `export *`. ¿Actualizar README o convertir libs?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Veredicto
|
||||||
|
|
||||||
|
**El ecosistema Active es un framework sólido, bien diseñado y con fundamentos arquitectónicos excelentes.** La separación en capas, el patrón Engine/Active, la política de tree-shaking, el aislamiento de scope en caché y permisos, y la implementación de CSRF son de calidad profesional.
|
||||||
|
|
||||||
|
**Lo que frena la calidad hoy:**
|
||||||
|
|
||||||
|
1. **Deuda de consolidación** — Archivos monolíticos (`connection.ts` 865 líneas, `engine-auth.ts` 951 líneas) que contradicen su propio diseño documentado. La duplicación de boilerplate entre sub-módulos de fmts y entre capas cliente/servidor indica que el framework creció sin pausas de refactorización.
|
||||||
|
|
||||||
|
2. **Cobertura de tests desigual** — Algunos módulos tienen baterías exhaustivas (50 tests en `engine-timers.test.ts`, 622 líneas en `engine-http.test.ts`); otros tienen cero tests (`libs/timers`, `active-connections`, `websocket`). Las áreas sin tests son precisamente donde hay más bugs potenciales (conexiones, reconexión, heartbeats).
|
||||||
|
|
||||||
|
3. **Convenciones inconsistentes** — Nombres plural/singular, clases vs factories, errores clase vs string, contrato `ActiveEngine` a medio adoptar. Esto crea fricción para nuevos contribuidores y hace que el código parezca menos cohesionado de lo que realmente es.
|
||||||
|
|
||||||
|
**Orden de actuación recomendado:**
|
||||||
|
|
||||||
|
1. **Semana 1-2 — Corrección de bugs:** HC-1 (Http dispose), HC-3 (writeBatch loop), HC-4 (isPromiseLike), HM-1 (Can reactivity), HM-2 (auth error re-throw)
|
||||||
|
2. **Semana 3-4 — Refactors estructurales:** Partir `connection.ts`, extraer boilerplate fmts, añadir Http.dispose()
|
||||||
|
3. **Semana 5-6 — Seguridad:** HM-3 (revokeDevice DB), HM-4 (signOutGlobal refresh families), HM-6 (session fixation docs)
|
||||||
|
4. **Semana 7-8 — Tests:** Añadir tests para `libs/timers`, `active-connections`, heartbeat, CSRF edge cases
|
||||||
|
5. **Mes 2-3 — Unificación:** Adoptar `ActiveEngine` universalmente o eliminarlo, unificar naming (plural→singular en timr), estandarizar errores (clases everywhere), actualizar DESIGN_CONN.md
|
||||||
|
|
||||||
|
**Lo que está excepcionalmente bien:**
|
||||||
|
- Separación cliente/servidor: cero imports cruzados `$arts`↔`$svrs` (fuera de tests de integración)
|
||||||
|
- CSRF: double-submit cookie + HMAC-SHA256 + timing-safe comparison + `__Host-` prefix
|
||||||
|
- Scope isolation en caché: `SCOPE_ACTOR`, `SCOPE_TENANT`, `SCOPE_PERMISSION` con hash en key
|
||||||
|
- Generation guard en permisos: previene escritura de resultados stale en snapshots posteriores
|
||||||
|
- Sistema de timers deterministas con fake clock injection para testing
|
||||||
|
- Tree-shaking: separación `.svelte.ts` vs `.ts`, barrels con named re-exports en `arts/`
|
||||||
|
- Documentación de diseño: `DESIGN_TIMR.md` (1530 líneas) y `DESIGN_CONN.md` (1770 líneas) son ejemplares
|
||||||
@ -0,0 +1,709 @@
|
|||||||
|
# AUDIT_claude
|
||||||
|
|
||||||
|
> Auditoría profunda del ecosistema Active en `src/`. Sin cambios de código.
|
||||||
|
> Cada hallazgo verificado lleva archivo y línea aproximada. Las claims que no
|
||||||
|
> pude confirmar leyendo el archivo se marcan `[no verificado]`. Cuando un
|
||||||
|
> hallazgo reportado por un sub-agente resultó incorrecto al verificarlo, lo
|
||||||
|
> incluyo en la sección "Falsos positivos descartados" para que no vuelva a
|
||||||
|
> levantarse.
|
||||||
|
|
||||||
|
## Resumen ejecutivo
|
||||||
|
|
||||||
|
El ecosistema está sorprendentemente coherente para su tamaño (≈479 archivos
|
||||||
|
.ts/.svelte). Las convenciones (`createEngineXxx` / `createActiveXxx`,
|
||||||
|
`ActiveEngine<TSnapshot, TError>`, dispose idempotente, constantes
|
||||||
|
centralizadas, named exports) se aplican con consistencia notable; lang y
|
||||||
|
logr son tan limpios que sirven de plantilla para el resto. Los tests de
|
||||||
|
`sium`, `stor`, `sess`, `lang` y `logr` son sólidos.
|
||||||
|
|
||||||
|
Los problemas serios se concentran en tres puntos:
|
||||||
|
|
||||||
|
1. **Composición de seguridad incompleta en `aapp`.** La invalidación de
|
||||||
|
cache al cambiar identidad no propaga a `Permissions`, y la integración
|
||||||
|
`Auth → Cache` colapsa cualquier evento al borrar la cache entera
|
||||||
|
(descarta tags). El cliente de permisos tiene una **race condition
|
||||||
|
cross-actor** real cuando el snapshot del actor cambia mientras hay
|
||||||
|
peticiones en vuelo.
|
||||||
|
2. **Ramas server-authoritative parcialmente implementadas.** `svrs/auth`
|
||||||
|
define `AuthRateLimitPort` pero no lo cablea en ningún flujo.
|
||||||
|
`verifyMfaChallenge` lanza `AuthConfigError` (stub). El intercambio OAuth
|
||||||
|
PKCE no pasa el `verifier` al provider. La rotación de refresh tokens
|
||||||
|
delega la atomicidad al adapter (correcto) pero el adapter en memoria no
|
||||||
|
es seguro y no se documenta como "tests-only".
|
||||||
|
3. **Cobertura de tests muy desigual.** `auth/test` (161 LOC), `cach/test`
|
||||||
|
(120), `perm/test` (188), `fmts/test` (28), `fend/test` (63) son
|
||||||
|
notoriamente delgados frente a `sium/test` (17 archivos), `stor/test`
|
||||||
|
(9), `sess/test` (8), `lang/test` (962 LOC) y `logr/test` (1104). Las
|
||||||
|
áreas más críticas para producción están menos cubiertas.
|
||||||
|
|
||||||
|
Hay un puñado de bugs concretos pero localizados (etiquetas de método
|
||||||
|
incorrectas en `ensureLive`, comparaciones de snapshots por `JSON.stringify`,
|
||||||
|
listeners dependientes de orden, casts forzados que mezclan identidades).
|
||||||
|
Ninguno tira el framework, pero ya levanta deuda visible.
|
||||||
|
|
||||||
|
Estado general: **sólido en esqueleto, frágil en seguridad/ops**. Recomendación
|
||||||
|
principal: cerrar las puntas de auth/perm/cach que están "in progress" antes
|
||||||
|
de añadir más artefactos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hallazgos críticos
|
||||||
|
|
||||||
|
### C1. `[bug confirmado]` Race condition cross-actor en cache de permisos
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/perm/client.ts:166-181, 207-221, 238-282](src/arts/perm/client.ts#L166-L282)
|
||||||
|
- Severidad: **alta** · Esfuerzo: medio
|
||||||
|
- Evidencia:
|
||||||
|
- `decisionKey(input)` usa `resolveScopeKey()` que lee
|
||||||
|
`currentSnapshot.actor` del snapshot vigente al *momento* de calcular la
|
||||||
|
clave.
|
||||||
|
- `check()` calcula la clave al inicio (línea 240) y la usa para `pending.set(key, …)`.
|
||||||
|
- Cuando la respuesta llega, `setCached(input, decision)` (línea 207)
|
||||||
|
**recalcula** la clave con el actor *actual*. Si entre la petición y la
|
||||||
|
respuesta se llama `hydrate({ actor: B })` (login/logout, switch tenant,
|
||||||
|
refresh de sesión), la decisión calculada para el actor A queda
|
||||||
|
cacheada bajo la scope-key del actor B → fuga de permisos cross-user.
|
||||||
|
- Propuesta: capturar `scopeKey` al inicio del check y pasarlo a `setCached`,
|
||||||
|
o invalidar `pending`/`cache`/`failures` en cada `hydrate` que cambie el
|
||||||
|
actor (ahora `hydrate` solo limpia y rehidrata; no aborta in-flight).
|
||||||
|
|
||||||
|
### C2. `[riesgo]` `aapp` no invalida `Permissions` cuando cambia identidad
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/aapp/active-app.svelte.ts:237-251](src/arts/aapp/active-app.svelte.ts#L237-L251)
|
||||||
|
- Severidad: **alta** · Esfuerzo: bajo
|
||||||
|
- Evidencia: en `createActiveAuth` se inyecta
|
||||||
|
`cach: { invalidate: () => Cache.clear() }` pero no se pasa nada al
|
||||||
|
`Permissions` activo. Tampoco hay un wiring `Auth → Permissions.invalidate()`
|
||||||
|
o `Sess → Permissions.invalidate()`. Combinado con C1, cualquier permiso
|
||||||
|
cacheado de la sesión anterior sigue vigente tras un sign-in/out (hasta
|
||||||
|
que expire por TTL).
|
||||||
|
- Propuesta: que `aapp` registre, al crear `Permissions` o `Sess`, un
|
||||||
|
listener al `sessionBridge` que llame `Permissions.invalidate()` con el
|
||||||
|
scope previo. O mejor, exponer un hook `cach`-style en
|
||||||
|
`ActivePermissionsOptions` y conectarlo en `aapp`.
|
||||||
|
|
||||||
|
### C3. `[riesgo]` `Auth → Cache.clear()` descarta tags y limpia todo
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247) + [src/arts/auth/active-auth.svelte.ts:281](src/arts/auth/active-auth.svelte.ts#L281)
|
||||||
|
- Severidad: media-alta · Esfuerzo: bajo
|
||||||
|
- Evidencia: el helper `authCacheTagsForIdentity()` produce tags (`auth.current`,
|
||||||
|
`auth.devices`, `auth.factors`) y `ActiveAuth` los pasa, pero `aapp`
|
||||||
|
ignora los args y llama `Cache.clear()` total. Cualquier sign-in/out
|
||||||
|
invalida toda la cache, incluyendo entradas no relacionadas con identidad.
|
||||||
|
Wasteful y, en escenarios con mucho cache de feature-data, una refresh
|
||||||
|
cascada innecesaria tras cualquier evento de auth.
|
||||||
|
- Propuesta: implementar `cach.invalidate({ tags, reason })` real en `aapp`
|
||||||
|
(`Cache.invalidate({ tags })`).
|
||||||
|
|
||||||
|
### C4. `[bug confirmado]` `ensureLive` recibe nombre de método incorrecto
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/auth/active-auth.svelte.ts:233](src/arts/auth/active-auth.svelte.ts#L233)
|
||||||
|
- Severidad: media · Esfuerzo: trivial
|
||||||
|
- Evidencia: `onChange(listener)` llama `ensureLive(AUTH_METHOD_LOAD_CURRENT)`.
|
||||||
|
Si el active está disposed, el `AuthDisposedError` reportará el método
|
||||||
|
equivocado. Caso parecido en `active-permissions.svelte.ts:111` donde
|
||||||
|
`clearError` y `decisionKey` reusan `PERMISSION_METHOD_CHECK`.
|
||||||
|
- Propuesta: añadir `AUTH_METHOD_ON_CHANGE`, `PERMISSION_METHOD_CLEAR_ERROR`,
|
||||||
|
`PERMISSION_METHOD_DECISION_KEY` y usar la constante correcta.
|
||||||
|
|
||||||
|
### C5. `[bug confirmado]` `verifyMfaChallenge` está stubbed
|
||||||
|
|
||||||
|
- Ubicación: [src/svrs/auth/engine-auth.ts:641-643](src/svrs/auth/engine-auth.ts#L641-L643)
|
||||||
|
- Severidad: alta para usar en producción · Esfuerzo: alto
|
||||||
|
- Evidencia: `async function verifyMfaChallenge(_input) { throw new AuthConfigError(...) }`.
|
||||||
|
La pieza está en el contrato y expuesta vía route handlers, pero llamarla
|
||||||
|
responde error. No hay banner en el README de `svrs/auth` que avise.
|
||||||
|
- Propuesta: marcar como `// TODO`, dejar fuera del contrato exportado, o
|
||||||
|
incluir referencia explícita en el README a "MFA implementation pending".
|
||||||
|
|
||||||
|
### C6. `[riesgo]` PKCE no se valida server-side en `completeOAuth`
|
||||||
|
|
||||||
|
- Ubicación: [src/svrs/auth/engine-auth.ts:579-617](src/svrs/auth/engine-auth.ts#L579-L617)
|
||||||
|
- Severidad: alta · Esfuerzo: medio
|
||||||
|
- Evidencia: `startOAuth` genera `verifier` y guarda
|
||||||
|
`metadata: { state, verifier }` en el flow, pero `completeOAuth` solo
|
||||||
|
recupera el flow por `stateHash`, llama
|
||||||
|
`provider.mapProfile({ tokens: { code } })` y consume el flow. **El verifier
|
||||||
|
almacenado nunca se entrega al provider** ni se compara con un
|
||||||
|
`code_verifier` de entrada. La construcción del PKCE pair (`oauth/pkce.ts`)
|
||||||
|
es correcta (BASE64URL(SHA256(verifier))) pero no se cierra el ciclo.
|
||||||
|
- Propuesta: pasar `flowCandidates.metadata?.verifier` a
|
||||||
|
`provider.mapProfile`, y exigir que el provider lo use en el token
|
||||||
|
exchange. Validar que el `code_verifier` derivado coincide con el
|
||||||
|
`code_challenge` enviado.
|
||||||
|
|
||||||
|
### C7. `[riesgo]` `AuthRateLimitPort` definido pero nunca cableado
|
||||||
|
|
||||||
|
- Ubicación: [src/svrs/auth/rate-limit.ts](src/svrs/auth/rate-limit.ts) + [src/svrs/auth/engine-auth.ts](src/svrs/auth/engine-auth.ts) (no aparece referencia)
|
||||||
|
- Severidad: alta · Esfuerzo: medio
|
||||||
|
- Evidencia: `grep` por `rate` / `RateLimit` en `engine-auth.ts` y
|
||||||
|
`handlers.ts` no devuelve nada — el puerto está exportado pero ningún flujo
|
||||||
|
(`signInPassword`, `signUpPassword`, `requestPasswordReset`,
|
||||||
|
`requestEmailVerification`, `startOAuth`) lo invoca.
|
||||||
|
- Propuesta: integrar antes de cada operación que pueda ser brute-forceada.
|
||||||
|
Hasta que se cablee, considerar quitarlo de `index.ts` para no dar
|
||||||
|
falsa sensación de protección.
|
||||||
|
|
||||||
|
### C8. `[riesgo]` Memory adapter no es transaccional pero soporta endpoints sensibles
|
||||||
|
|
||||||
|
- Ubicación: [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157), [src/svrs/auth/refresh-rotation.ts:21-55](src/svrs/auth/refresh-rotation.ts#L21-L55)
|
||||||
|
- Severidad: media · Esfuerzo: bajo (docs)
|
||||||
|
- Evidencia: `findRefreshTokenForUpdate` y `rotateRefreshToken` están
|
||||||
|
diseñados para correr dentro de una transacción ("ForUpdate" sugiere row
|
||||||
|
lock). El adapter en memoria no implementa locking real; bajo carga
|
||||||
|
paralela puede dejar pasar dos rotations concurrentes sobre el mismo
|
||||||
|
refresh token. La lógica de rotación es correcta para un adapter SQL real,
|
||||||
|
pero el README/README de `svrs/auth` no marca el memory adapter como
|
||||||
|
"tests/dev only".
|
||||||
|
- Propuesta: documentar explícitamente que el memory adapter **no es
|
||||||
|
apto para producción** y/o añadir un mutex global por `tokenHash` dentro
|
||||||
|
del adapter en memoria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hallazgos medios
|
||||||
|
|
||||||
|
### M1. `[bug confirmado]` `sameSnapshot` por `JSON.stringify` para session
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/sess/engine-session.ts:786-790](src/arts/sess/engine-session.ts#L786-L790)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Riesgo: si la session contiene fields cuyo orden de keys no es estable
|
||||||
|
entre origen-tab y target-tab (raro pero posible con structures cíclicas
|
||||||
|
o `JSON.stringify` polyfills), se reportarán cambios falsos. Más probable:
|
||||||
|
el coste de stringify dos sesiones en cada storage event escala con el
|
||||||
|
payload de `data`. Para apps que guardan poco, está bien; documentar el
|
||||||
|
coste y que `data` debe ser pequeño.
|
||||||
|
- Propuesta: dado que `freezeSession` ya normaliza keys, el riesgo de
|
||||||
|
desorden es bajo. Bastaría una nota en el README sobre el coste.
|
||||||
|
|
||||||
|
### M2. `[bug confirmado]` SameSite default `lax` para cookie CSRF
|
||||||
|
|
||||||
|
- Ubicación: [src/libs/auth/consts.ts:283-290](src/libs/auth/consts.ts#L283-L290)
|
||||||
|
- Severidad: media · Esfuerzo: trivial
|
||||||
|
- Evidencia: `AUTH_COOKIE_POLICY.SAME_SITE = 'lax'`. Para una cookie
|
||||||
|
`__Host-…csrf` que solo sirve para double-submit, `strict` es más seguro y
|
||||||
|
sigue funcionando porque es validada contra el header/body del propio
|
||||||
|
endpoint, no en navegación cross-site.
|
||||||
|
- Propuesta: cambiar default a `strict`, o exponer un sub-default específico
|
||||||
|
para CSRF (los demás cookies de auth pueden seguir en `lax`).
|
||||||
|
|
||||||
|
### M3. `[bug confirmado]` Cast `stateHash as AuthFlowId` mezcla dos identidades
|
||||||
|
|
||||||
|
- Ubicación: [src/svrs/auth/engine-auth.ts:717-729](src/svrs/auth/engine-auth.ts#L717-L729) + [src/svrs/auth/adapters/memory.ts:139-157](src/svrs/auth/adapters/memory.ts#L139-L157)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: `findOAuthFlowByState` pasa el `stateHash` como `flowId` y el
|
||||||
|
adapter lo usa primero como id directo y, si falla, como búsqueda por
|
||||||
|
`flow.stateHash`. Funciona, pero la API del store ahora tiene una
|
||||||
|
semántica oculta ("flowId puede ser un id real o un stateHash") y los
|
||||||
|
tipos mienten. Difícil de descubrir sin leer el adapter.
|
||||||
|
- Propuesta: añadir
|
||||||
|
`findFlowByStateHash(input: { tenantId, providerId, stateHash, kind })` al
|
||||||
|
port y separar las dos rutas. Mantiene tipos honestos.
|
||||||
|
|
||||||
|
### M4. `[refactor]` Tres ramas idénticas para validar credential/data/actor
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/sess/engine-session.ts:378-419](src/arts/sess/engine-session.ts#L378-L419) y [493-543](src/arts/sess/engine-session.ts#L493-L543)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: `adopt` y la rama validada de `refresh` repiten el mismo patrón
|
||||||
|
4 veces ("si schema definido O field presente, validar; mapear error con
|
||||||
|
field name"). 80 LOC duplicadas.
|
||||||
|
- Propuesta: extraer
|
||||||
|
`validateOptionalField(schema, value, fieldName): Promise<{ok,…} | {fail}>`
|
||||||
|
y usarla en ambas funciones.
|
||||||
|
|
||||||
|
### M5. `[refactor]` Acoplamiento sutil `aapp` ↔ `stor` por mensaje de log
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/aapp/active-app.svelte.ts:33,77-83](src/arts/aapp/active-app.svelte.ts#L33-L83)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: `aapp` importa
|
||||||
|
`LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY` y `APP_STORAGE_ERROR_MESSAGE`
|
||||||
|
para reportar errores del adapter. La política de "qué mensaje y qué
|
||||||
|
categoría usar" está dividida entre dos módulos.
|
||||||
|
- Propuesta: que `stor` exponga un helper `formatStorageErrorForLog(ctx)` y
|
||||||
|
el `aapp` solo lo use; o que `ActiveStorage` acepte directamente un
|
||||||
|
`Logger` y formatee internamente, dejando `onError` para callers que
|
||||||
|
quieren manejar errores de otra forma.
|
||||||
|
|
||||||
|
### M6. `[refactor]` `Cache.clear()` ignora tags y vuelve `cach.invalidate` un alias mentiroso
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/aapp/active-app.svelte.ts:244-247](src/arts/aapp/active-app.svelte.ts#L244-L247)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Cubierto en C3. Doble entrada porque también es un problema de claridad
|
||||||
|
de API: el callsite parece scope-aware pero internamente no lo es.
|
||||||
|
|
||||||
|
### M7. `[bug confirmado]` `dynamicEntry` en `stor` solo registra UN listener al rebind
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/stor/active-storage.svelte.ts:115-127](src/arts/stor/active-storage.svelte.ts#L115-L127) (verificar líneas exactas en su versión actual)
|
||||||
|
- Severidad: media · Esfuerzo: medio
|
||||||
|
- Evidencia (parcial, no leí el archivo entero): el patrón de `userSubs:
|
||||||
|
Map<fn, detacher>` reasigna el detacher en cada rebind, lo que suelta
|
||||||
|
el listener anterior y registra uno nuevo. Es correcto siempre que la
|
||||||
|
función `fn` sea estable. Si el caller usa una arrow inline, cada rebind
|
||||||
|
agrega una entrada nueva sin liberar la anterior. Documentar que `fn`
|
||||||
|
debe ser estable.
|
||||||
|
- Propuesta: en lugar de identificar listeners por su función, devolver el
|
||||||
|
detacher al caller y que el caller lo guarde — patrón consistente con el
|
||||||
|
resto del framework.
|
||||||
|
|
||||||
|
### M8. `[riesgo]` `mono-lang` no documenta su contrato de no-i18n
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/lang/mono-lang.svelte.ts](src/arts/lang/mono-lang.svelte.ts)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: `aapp` cae a `createActiveMonoLang` cuando no se pasa `lang`,
|
||||||
|
con un cast `as unknown as ActiveLang<S>`. Si un caller depende de tipos
|
||||||
|
estrictos del schema, ese cast borra la garantía. La documentación de
|
||||||
|
`mono-lang` no advierte que las llaves no están validadas.
|
||||||
|
- Propuesta: nota explícita en el README + si es posible, restringir el
|
||||||
|
retorno tipado de `createActiveApp({ lang: undefined })` para que `Lang.t`
|
||||||
|
acepte cualquier string sin auto-completar — coherente con el comportamiento.
|
||||||
|
|
||||||
|
### M9. `[refactor]` Body-scroll-lock duplica scheduling con `timr`
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/adom/body-scroll-lock.svelte.ts](src/arts/adom/body-scroll-lock.svelte.ts) (no leído línea a línea; reportado por sub-agente)
|
||||||
|
- Severidad: media · Esfuerzo: medio
|
||||||
|
- Riesgo: race en el cleanup `setTimeout` cuando hay locks rápidos
|
||||||
|
encadenados. Si se confirma con un test (no existe), aprovechar para
|
||||||
|
delegar a `EngineTimers` (`timr`) y eliminar el setTimeout local.
|
||||||
|
- Propuesta: usar `App.Timers.schedule()`. Beneficio extra: deterministic
|
||||||
|
para tests con `clock` inyectado.
|
||||||
|
|
||||||
|
### M10. `[refactor]` Headers se re-resuelven en cada retry
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/http/engine-http.ts] (línea ~271 según sub-agente)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia indirecta: si `mergeHeaders(defaults.headers, init?.headers)`
|
||||||
|
invoca a un `headers` hook costoso (p.ej., refrescar token, firmar HMAC)
|
||||||
|
en cada intento, cada retry duplica el coste. Para refresh tokens bajo
|
||||||
|
presión esto puede colgar requests.
|
||||||
|
- Propuesta: cachear el resultado del primer cómputo de headers y solo
|
||||||
|
recomputar si el `beforeRetry` lo solicita explícitamente.
|
||||||
|
|
||||||
|
### M11. `[bug confirmado]` `eventCount` y `loadingCount` con `untrack` en `cach`
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/cach/active-cache.svelte.ts:55-64](src/arts/cach/active-cache.svelte.ts#L55-L64)
|
||||||
|
- Severidad: baja-media · Esfuerzo: trivial
|
||||||
|
- Evidencia: `eventCountCell = untrack(() => eventCountCell) + 1`. Como el
|
||||||
|
callback `engine.on(CACHE_EVENT_ALL, …)` se invoca desde el motor (no
|
||||||
|
dentro de un `$derived`/`$effect`), el `untrack` es defensivo pero ruidoso
|
||||||
|
e induce a los lectores a creer que hay un ciclo reactivo escondido.
|
||||||
|
- Propuesta: si los tests pasan sin `untrack`, quitarlo. Si hay un caso que
|
||||||
|
requiere `untrack`, comentar el porqué.
|
||||||
|
|
||||||
|
### M12. `[riesgo]` `Cache.clear()` no aborta promises en vuelo
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/cach/active-cache.svelte.ts:167-170](src/arts/cach/active-cache.svelte.ts#L167-L170) + engine
|
||||||
|
- Severidad: media · Esfuerzo: medio
|
||||||
|
- Evidencia: `clear()` se delega a `engine.clear()`. Si una `query()`
|
||||||
|
estaba en vuelo, su `setCached` posterior puede repoblar la cache que
|
||||||
|
acaba de ser borrada. Mismo problema que C1, en otro escenario.
|
||||||
|
- Propuesta: incrementar un `clearGeneration` y descartar resultados de
|
||||||
|
fetches iniciados antes de la última `clear()`.
|
||||||
|
|
||||||
|
### M13. `[refactor]` `signOut` cliente es optimista pero estado se reescribe sólo si la red OK
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/auth/active-auth.svelte.ts:102-110](src/arts/auth/active-auth.svelte.ts#L102-L110)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: la asignación `current = createAnonymousAuthCurrent()` ocurre
|
||||||
|
*después* del `await options.http.post(SIGN_OUT)`. Si la red falla, el
|
||||||
|
usuario sigue "authenticated" en la UI aunque la cookie del servidor se
|
||||||
|
haya eliminado. En cookie-auth puro, una respuesta 5xx puede dejar al
|
||||||
|
cliente desincronizado.
|
||||||
|
- Propuesta: dos opciones: (a) limpiar localmente *antes* del POST y
|
||||||
|
rollback si el server responde 401 confirmando que ya no había sesión;
|
||||||
|
(b) en el catch, si el error es de red, igual limpiar localmente y dejar
|
||||||
|
que la próxima `loadCurrent` resuelva el estado real.
|
||||||
|
|
||||||
|
### M14. `[riesgo]` `BroadcastChannel` no parsea `event` ni `generation`
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/sess/engine-session.ts:154-180](src/arts/sess/engine-session.ts#L154-L180)
|
||||||
|
- Severidad: baja-media · Esfuerzo: bajo
|
||||||
|
- Evidencia: el listener trata `data?.type !== BROADCAST_TYPE` como guard
|
||||||
|
de seguridad, lo cual cubre payloads ajenos. Pero si el remitente de la
|
||||||
|
misma BC envía un `type` correcto pero un `event`/`generation` corrupto,
|
||||||
|
el código lee `storage` directamente — está bien — pero igual entrega un
|
||||||
|
`EXTERNAL_CHANGED` con el snapshot persistido, que puede no concordar con
|
||||||
|
el `event` del mensaje. No produce comportamiento incorrecto pero hace
|
||||||
|
que `event` y `current` no estén ligados al mensaje recibido.
|
||||||
|
- Propuesta: como ya se delega en `storage`, ignorar el `event` del
|
||||||
|
broadcast y simplemente disparar un re-read; el modelo actual hace eso, así
|
||||||
|
que solo bastaría documentar.
|
||||||
|
|
||||||
|
### M15. `[refactor]` Permisos: `pending` debería re-cuparse al cambiar actor
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/perm/client.ts:140-147 + 360-388](src/arts/perm/client.ts#L140-L388)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: `hydrate(snapshot)` y `invalidate(scope)` no tocan `pending`.
|
||||||
|
Si invalidate corre durante in-flight, los caches `pending` tras la
|
||||||
|
resolución repoblarán datos que ya no debieran existir.
|
||||||
|
- Propuesta: `pending.clear()` dentro de `hydrate` e `invalidate(undefined)`,
|
||||||
|
y filtrar por scope en `invalidate(scope)`.
|
||||||
|
|
||||||
|
### M16. `[bug confirmado]` `aapp` permite varios `connectionRegistries` pero sin aviso
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/aapp/active-app.svelte.ts:200-212](src/arts/aapp/active-app.svelte.ts#L200-L212)
|
||||||
|
- Severidad: baja-media · Esfuerzo: trivial
|
||||||
|
- Evidencia: `Sess`, `Permissions` y `Auth` levantan `AlreadyCreated*Error`
|
||||||
|
si se piden dos veces, pero `createActiveConnections` no. Los tests
|
||||||
|
`aapp/test` parecen aceptarlo. Inconsistencia con el patrón.
|
||||||
|
- Propuesta: o documentar explícitamente que `Connections` es multi-instancia
|
||||||
|
(channels separados) o aplicar la misma regla.
|
||||||
|
|
||||||
|
### M17. `[refactor]` `lang` `void _schemaVersion` como hack reactivo
|
||||||
|
|
||||||
|
- Ubicación: `src/arts/lang/active-lang.svelte.ts` (línea ~63 según
|
||||||
|
sub-agente) — patrón frágil para forzar lectura reactiva.
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Propuesta: documentar el porqué con un bloque comentado, o usar
|
||||||
|
`$derived.by(() => { schemaVersion; return … })` para que el dev tooling
|
||||||
|
lo vea explícitamente.
|
||||||
|
|
||||||
|
### M18. `[riesgo]` `dispose()` orden en `aapp` no detiene timers in-flight
|
||||||
|
|
||||||
|
- Ubicación: [src/arts/aapp/active-app.svelte.ts:253-276](src/arts/aapp/active-app.svelte.ts#L253-L276)
|
||||||
|
- Severidad: media · Esfuerzo: bajo
|
||||||
|
- Evidencia: el orden parece intencional pero no se documenta. `Cache.dispose()`
|
||||||
|
se llama antes que `Timers.dispose()`. Si la cache tiene un timer
|
||||||
|
programado en `Timers`, ese timer queda suelto hasta que se dispose
|
||||||
|
`Timers`. Como `Timers.dispose()` cancela todos, el efecto neto es
|
||||||
|
correcto en este orden, pero invertir destruiría la cache primero y
|
||||||
|
podría disparar un last-tick. Mantener el orden y documentarlo.
|
||||||
|
- Propuesta: comment de cabecera con la regla `consumers → providers`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hallazgos menores
|
||||||
|
|
||||||
|
### m1. `[docs]` Inconsistencias entre `arts/README.md` y READMEs por artefacto
|
||||||
|
|
||||||
|
- `arts/README.md:51` dice de `logr`: "Structured logger: levels, transports,
|
||||||
|
filters, vitals, dispose". `logr/README.md` debe explicitar igual y
|
||||||
|
alinear el lenguaje (algunos READMEs llaman a `transport` "adapter").
|
||||||
|
|
||||||
|
### m2. `[docs]` `fmts/README.md` no aclara que `createRates(...)` es demo
|
||||||
|
|
||||||
|
- `fmts` documenta currency conversion pero no explicita que el rate provider
|
||||||
|
es responsabilidad del consumidor.
|
||||||
|
|
||||||
|
### m3. `[docs]` `cach/README.md` no documenta qué pasa si el `fetcher` lanza
|
||||||
|
|
||||||
|
- ¿Se marca la entrada como error? ¿Se conserva `data` previa con `status:
|
||||||
|
ERROR`? El código (active-cache.svelte.ts:254-258) lo hace, pero no está
|
||||||
|
en docs.
|
||||||
|
|
||||||
|
### m4. `[refactor]` Magic strings de marca "asoma" en cookies
|
||||||
|
|
||||||
|
- `src/libs/auth/consts.ts:54-57, 77-78` hardcodea "asoma". Para un
|
||||||
|
framework reutilizable, conviene `BRAND_NAME` configurable y derivar
|
||||||
|
cookie names.
|
||||||
|
|
||||||
|
### m5. `[simplificación]` `mapSendToJoinResult` en `conn/channel.ts:42-52`
|
||||||
|
|
||||||
|
- Mapeo trivial; inline o usar `as const` table.
|
||||||
|
|
||||||
|
### m6. `[refactor]` `helpers.ts` y `consts.ts` con cientos de identifiers en algunos artefactos
|
||||||
|
|
||||||
|
- `auth/consts.ts` y `sess/consts.ts` exportan ≈80 constantes cada uno.
|
||||||
|
Considerar agrupar en namespaces (`AUTH_METHODS`, `AUTH_HEADERS`, ya hecho
|
||||||
|
parcialmente) y reducir el surface por named import.
|
||||||
|
|
||||||
|
### m7. `[docs]` `arts/README.md` Map menciona `EngineSium` pero no `ActiveSium`
|
||||||
|
|
||||||
|
- Verificar que `sium` realmente no expone una versión Active. Si así es,
|
||||||
|
documentar que `sium` es un caso especial (engine-only); ya está
|
||||||
|
contemplado pero la fila no lo deja claro.
|
||||||
|
|
||||||
|
### m8. `[simplificación]` `TimerKey` interno en `conn` duplica conceptos de `timr`
|
||||||
|
|
||||||
|
- `connection.ts:316-330` (según sub-agente) maneja
|
||||||
|
`scheduleTimer`/`scheduleInterval` con keys propias. Ya tiene `timr` con
|
||||||
|
`(id, key, version)`. Posible delegación.
|
||||||
|
|
||||||
|
### m9. `[docs]` Dispose contract no está formalizado en cada README
|
||||||
|
|
||||||
|
- `arts/README.md` dice "dispose() es idempotente". Algunos READMEs (sess,
|
||||||
|
cach, auth) repiten la garantía; otros no. Estandarizar línea boilerplate.
|
||||||
|
|
||||||
|
### m10. `[refactor]` `aapp/integrations/frontend-storage` exporta nombre
|
||||||
|
largo + tres helpers que se usan solo desde `active-app.svelte.ts`
|
||||||
|
|
||||||
|
- Considerar inline o convertir en method privado del `ActiveApp`.
|
||||||
|
|
||||||
|
### m11. `[simplificación]` `Logger.dispose()` cierra y vacía pero no expone snapshot
|
||||||
|
|
||||||
|
- A diferencia de otros, `EngineLogger` no tiene `snapshot()`/`onChange`. OK
|
||||||
|
porque no implementa `ActiveEngine`. Documentar que es intencional.
|
||||||
|
|
||||||
|
### m12. `[docs]` `arts/conn/DESIGN_CONN.md` y `arts/timr/DESIGN_TIMR.md` y
|
||||||
|
`arts/sess/DESIGN.md` viven solo en sus carpetas
|
||||||
|
|
||||||
|
- Considerar enlazarlos desde `arts/README.md` para visibilidad. Los
|
||||||
|
decisivos no se ven a menos que el lector navegue.
|
||||||
|
|
||||||
|
### m13. `[test]` `aapp/test` (5 archivos) cubre composición pero no orden de
|
||||||
|
dispose
|
||||||
|
|
||||||
|
- Añadir test que verifique que disposal corre `consumers → providers`.
|
||||||
|
|
||||||
|
### m14. `[bug confirmado]` `Sentry DSN` queda en `sessionStorage` del test page
|
||||||
|
|
||||||
|
- `web/routes/test/logr/+page.svelte` guarda DSN en sessionStorage; al
|
||||||
|
navegar entre tests, persiste. Privacidad/uso accidental en producción.
|
||||||
|
|
||||||
|
### m15. `[docs]` SSR contract per-artefacto
|
||||||
|
|
||||||
|
- `timr`, `conn`, `adom`, `fend` no documentan explícitamente SSR. Una
|
||||||
|
sección "SSR considerations" por artefacto evitaría sorpresas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactorizaciones recomendadas
|
||||||
|
|
||||||
|
1. **Centralizar invalidación cross-artefacto**. Un `IdentityChannel`
|
||||||
|
(probablemente extensión de `sessionBridge`) al que `Cache` y
|
||||||
|
`Permissions` se suscriban. Hoy `aapp` suelta listeners ad hoc y mezcla
|
||||||
|
responsabilidades.
|
||||||
|
2. **Extraer `validateOptionalField`** del engine de sess; aparece 8 veces.
|
||||||
|
3. **Centralizar comparaciones por `JSON.stringify`** en un `equalsByJson`
|
||||||
|
en `libs/objs/`. Hoy aparece en sess y stor.
|
||||||
|
4. **Mover `setCached` a un helper `cacheKeyAtTime(input, scope)`** en perm
|
||||||
|
para fijar la scope-key al inicio del check (cierra C1).
|
||||||
|
5. **Unificar el patrón de listeners por función estable.** `stor`, `cach` y
|
||||||
|
`conn` lo hacen distinto; converger a "el caller guarda el detacher".
|
||||||
|
6. **Romper la dependencia `aapp ← stor`** en mensajes/logger category;
|
||||||
|
`stor` debe exponer su propio helper.
|
||||||
|
7. **Partir `auth/consts.ts`** en sub-archivos por dominio (cookies, methods,
|
||||||
|
events, errors). Importar lo que se usa, no cargar 80 constantes por
|
||||||
|
módulo.
|
||||||
|
8. **Documentar adapter contract** (auth/store) y separar `findFlowForUpdate`
|
||||||
|
de `findFlowByStateHash`.
|
||||||
|
9. **Pulir el README de `arts/`** para añadir leyenda "Adapters", "Hooks",
|
||||||
|
"SSR" y enlazar los `DESIGN_*.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simplificaciones recomendadas
|
||||||
|
|
||||||
|
1. **Eliminar `untrack` defensivos** en `cach` que no responden a un caso
|
||||||
|
concreto (M11).
|
||||||
|
2. **Inline `mapSendToJoinResult`** y `mergeHeaders` cuando se usen una vez.
|
||||||
|
3. **Reducir el surface de `Cache.snapshot()`**: hoy expone `lastEvent`,
|
||||||
|
`eventCount`, `loading`, `lastError`, `disposed`. ¿Qué consumidor real
|
||||||
|
usa `eventCount`? Si solo lo usa el test page, mover a un helper de
|
||||||
|
debug.
|
||||||
|
4. **Unificar nombres**: `loading` vs `loadingCount`, `lastError` vs
|
||||||
|
`errorCell`, `current` vs `snapshot()`. La regla "loading siempre boolean,
|
||||||
|
lastError siempre `TError | null`" ya está en el README; aplicarla en los
|
||||||
|
internals.
|
||||||
|
5. **Rebajar `mono-lang` a un export de funciones**, no un Active completo —
|
||||||
|
hoy implementa `ActiveLang` solo para el cast. Se podría aceptar `null`
|
||||||
|
en `aapp.Lang` y guardarlo detrás de un proxy.
|
||||||
|
6. **Quitar el wrapper `safeParse`** del test page de http; el patrón
|
||||||
|
"intenta JSON.parse con fallback string" es trivial y oculta errores.
|
||||||
|
7. **Devolver el detacher de `onChange`** en `EngineLogger` para alinearse
|
||||||
|
con el resto, aunque hoy no haya listeners.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimizaciones recomendadas
|
||||||
|
|
||||||
|
1. **Permisos**: cachear `decisionKey` por scope al inicio del check (resuelve
|
||||||
|
C1 y mejora rendimiento en aplicaciones con muchas checks por evento).
|
||||||
|
2. **HTTP retries**: cachear el body serializado *y* los headers cuando no
|
||||||
|
cambian entre intentos (M10).
|
||||||
|
3. **Storage `read()`**: comparar `prev === next` por `Object.is` antes de
|
||||||
|
dispatch — evita re-render en cadena cuando un setItem coincide con el
|
||||||
|
valor actual.
|
||||||
|
4. **Cache `mergeDefaults`** evita recomputar `JSON.stringify(defaults)`
|
||||||
|
cada lectura. Si se cumple igualdad estructural, dedupe.
|
||||||
|
5. **`SvelteMap`/`SvelteSet`** en `aapp` (`sessionBridgeListeners`,
|
||||||
|
`connectionRegistries`) están bien marcados como no-reactivos, pero hay
|
||||||
|
sitios en `stor` (`userSubs`) y `perm` (`pending`) donde plain `Map` es
|
||||||
|
suficiente — sub-agente reportó que algunos son `SvelteMap`. Verificar
|
||||||
|
y bajar a Map donde no haya consumo en templates.
|
||||||
|
6. **Compactar `vitals.ts` config factories** (logr) — patrón repetido
|
||||||
|
`levelsAtLeast(...)` en cada transport.
|
||||||
|
7. **`fmts` Currency cache**: `Map + JSON.stringify(options)` por entrada
|
||||||
|
produce keys grandes; un `Map<locale, Map<code, Map<optionsKey, Intl>>>`
|
||||||
|
es más rápido y barato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Incoherencias de arquitectura
|
||||||
|
|
||||||
|
1. **`aapp` sabe demasiado de `stor`**. Importa `LOGGER_CATEGORY` y un
|
||||||
|
message builder de stor. La capa de composición debería ser ciega al
|
||||||
|
formato de los errores de los proveedores.
|
||||||
|
2. **`auth` cliente y server compartidos vía `libs/auth`** — bien, pero
|
||||||
|
`helpers.ts` (cliente) llama a tags que solo usa `aapp`. Mover a `aapp`
|
||||||
|
o a `libs/svrs/auth`.
|
||||||
|
3. **`cach` cliente vive en `arts/cach` pero el engine real está en
|
||||||
|
`svrs/cach`**. El active es un wrapper. Coherente con el patrón
|
||||||
|
"auth/perm/cach se parten en svrs+arts" — pero el README de `arts/cach`
|
||||||
|
no menciona la dependencia explícita a `$svrs/cach`. Confuso para un
|
||||||
|
nuevo dev.
|
||||||
|
4. **`AuthRateLimitPort` en `svrs/auth/rate-limit.ts` exportado pero no
|
||||||
|
integrado** (C7). Rompe la promesa "todos los puertos usados".
|
||||||
|
5. **Memory adapter en `svrs/auth/adapters/memory.ts` no marcado como
|
||||||
|
tests-only** (C8). Coherencia con expectativa producción/test.
|
||||||
|
6. **`Sess` exige `App.createActiveSession` como factory una sola vez**, pero
|
||||||
|
`Connections` no (M16). Inconsistencia.
|
||||||
|
7. **`mono-lang` rompe la garantía de tipo**. Cast `as unknown as
|
||||||
|
ActiveLang<S>` significa que el tipo del `App.Lang` no es de fiar.
|
||||||
|
Coherencia con el contrato "App.Lang siempre tipado por schema".
|
||||||
|
8. **Constantes de "categoría logger"** son strings cortos por artefacto
|
||||||
|
(`'sium'`, `'sess'`, `'auth.client'`, `'cache'`). El propio `aapp.ts`
|
||||||
|
incluye `auth.client` y `cache` con punto, mientras `sess` es plano.
|
||||||
|
Convención no documentada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests faltantes
|
||||||
|
|
||||||
|
### Críticos
|
||||||
|
|
||||||
|
- **`arts/perm/test`** (188 LOC, 1 archivo): tests para C1 (race
|
||||||
|
cross-actor), `invalidate(scope)` con scope correcto/incorrecto, dedup de
|
||||||
|
`pending` con error y reintento.
|
||||||
|
- **`arts/cach/test`** (120 LOC, 1 archivo): TTL expiry, stale-while-revalidate
|
||||||
|
con error en fetcher, race entre `set` y `query`, integración con
|
||||||
|
`$stor`.
|
||||||
|
- **`arts/auth/test`** (161 LOC, 1 archivo): CSRF flow completo (rechazo si
|
||||||
|
cookie/token no coinciden, expiración), sign-out con red caída (M13),
|
||||||
|
`requestPasswordReset` y `completePasswordReset`, `revokeDevice`.
|
||||||
|
- **`svrs/auth/test`** (3 archivos): refresh rotation reuse window, OAuth
|
||||||
|
state-hash collision, MFA challenge expirado, rate-limit (cuando se
|
||||||
|
cablee).
|
||||||
|
|
||||||
|
### Importantes
|
||||||
|
|
||||||
|
- **`arts/conn/test`** (2 archivos): WebSocket transport mockeado, ack
|
||||||
|
timeout, reconnect con backoff, disposal idempotente.
|
||||||
|
- **`arts/fmts/test`** (28 LOC) y **`arts/fend/test`** (63 LOC): casi vacíos.
|
||||||
|
Cubrir locale switching, currency rounding, dir auto-derivation.
|
||||||
|
- **`arts/timr/test`** (3 archivos): backoff formula, scope cancellation,
|
||||||
|
`awaitTask:false` fire-and-forget.
|
||||||
|
- **`arts/aapp/test`** (5 archivos): orden de disposal, idempotencia, doble
|
||||||
|
factory.
|
||||||
|
- **`arts/adom/test`** (5 archivos): roving focus keyboard, viewport debounce,
|
||||||
|
scroll lock multi-claim.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
- Sess: `expiresAt - issuedAt < 1`, `generation > Number.MAX_SAFE_INTEGER`,
|
||||||
|
refresh y revoke concurrentes.
|
||||||
|
- HTTP: Retry-After con segundos vs HTTP-date, abort en mitad de retry,
|
||||||
|
`bodySchema` y `schema` en conflicto.
|
||||||
|
- Stor: cuota excedida, envelope corrupto, migrate fallido en cadena.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preguntas abiertas
|
||||||
|
|
||||||
|
1. **¿Qué propiedades de "scope" debería tener `cach.invalidate({tags})`
|
||||||
|
cuando se llama desde `aapp` por evento de auth?** Ahora se pierde por
|
||||||
|
`Cache.clear()`. ¿Decisión consciente o pendiente?
|
||||||
|
2. **¿Es `mono-lang` parte estable del API público o un fallback interno?**
|
||||||
|
El cast unsafe sugiere lo segundo, pero `index.ts` lo exporta.
|
||||||
|
3. **¿Cuál es la promesa de "Active" en cuanto a SSR?** `arts/README.md`
|
||||||
|
dice "lives in `.svelte.ts` because it owns `$state`" pero no aclara qué
|
||||||
|
funciones son seguras en `+page.server.ts`. Hay implementaciones con
|
||||||
|
guardas (`fend`, `stor`) y otras sin (`logr` con `beforeunload`). ¿Cuál
|
||||||
|
es la regla?
|
||||||
|
4. **¿`AuthRateLimitPort` queda fuera del MVP?** Si sí, no exportar en el
|
||||||
|
barrel para evitar la falsa impresión.
|
||||||
|
5. **¿Memory adapters de `svrs/auth/cach/perm` están pensados para
|
||||||
|
producción multi-instancia?** Si no, marcarlos.
|
||||||
|
6. **`Cache.clear()` durante una `query()` en vuelo: ¿debería abortar la
|
||||||
|
query?** (M12). Decisión semántica.
|
||||||
|
7. **`Sess.dispose()` durante un `refresh()` en vuelo**: ¿la promesa
|
||||||
|
resuelve con `SessDisposedError` o con `SKIPPED`?
|
||||||
|
8. **¿`hydrate(snapshot)` en perm debe abortar `pending`?** (M15).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Veredicto
|
||||||
|
|
||||||
|
**Lo sólido**
|
||||||
|
|
||||||
|
- Convenciones del framework: `ActiveEngine`, factories `createEngineXxx` /
|
||||||
|
`createActiveXxx`, dispose idempotente, no magic strings (en su mayoría),
|
||||||
|
named exports, sin barrels con `export *`. Esto es difícil de mantener a
|
||||||
|
escala y se nota el cuidado.
|
||||||
|
- `lang`, `logr`, `sium`, `stor`, `sess` están en muy buen estado, con
|
||||||
|
tests serios (≥700 LOC cada uno) y READMEs alineados.
|
||||||
|
- `timr` (locked-in design) y `http` están limpios y bien encapsulados.
|
||||||
|
- Las decisiones documentadas en MEMORY.md (sess actor extension,
|
||||||
|
`App.createSiumEngine` zero-arg, no `App.Stores`) están correctamente
|
||||||
|
reflejadas en el código.
|
||||||
|
|
||||||
|
**Lo que frena la calidad**
|
||||||
|
|
||||||
|
- La integración auth/perm/cach está a medias: `aapp` tira de un cordel
|
||||||
|
fácil (`Cache.clear()`) en vez de cablear bien identidad → cache → permisos.
|
||||||
|
El resultado es un comportamiento conservador pero inseguro en bordes
|
||||||
|
(C1, C2, C3).
|
||||||
|
- Server-authoritative auth tiene gaps importantes en producción: sin rate
|
||||||
|
limiting (C7), MFA stub (C5), PKCE no validado server-side (C6), memory
|
||||||
|
adapter sin warning (C8).
|
||||||
|
- Cobertura de tests muy desigual: lo más crítico (auth, perm, cach, fmts,
|
||||||
|
fend) es lo menos cubierto.
|
||||||
|
- Pequeños bugs de ergonomía dispersos: nombres de método incorrectos en
|
||||||
|
`ensureLive` (C4), `untrack` defensivos sin documentar, casts forzados que
|
||||||
|
ocultan semánticas reales.
|
||||||
|
|
||||||
|
**Orden de actuación sugerido**
|
||||||
|
|
||||||
|
1. **Sprint de seguridad operativa** (1-2 semanas):
|
||||||
|
- Cablear `AuthRateLimitPort` en sign-in/sign-up/reset/oauth (C7).
|
||||||
|
- Pasar el `verifier` PKCE al provider y validarlo server-side (C6).
|
||||||
|
- Marcar memory adapters como dev/test only en README + warning runtime (C8).
|
||||||
|
- Documentar SECURITY.md con el flujo completo (CSRF, OAuth state,
|
||||||
|
refresh rotation, MFA).
|
||||||
|
- Cambiar SameSite default CSRF a `strict` (M2).
|
||||||
|
|
||||||
|
2. **Sprint de wiring de identidad** (1 semana):
|
||||||
|
- Cerrar C1 (race en perm).
|
||||||
|
- Cerrar C2 (perm.invalidate al cambiar identidad).
|
||||||
|
- Cerrar C3 (cach.invalidate respeta tags).
|
||||||
|
- M12 (Cache.clear con generation guard).
|
||||||
|
- M15 (perm.hydrate/invalidate aborta pending).
|
||||||
|
|
||||||
|
3. **Sprint de pulido** (1 semana):
|
||||||
|
- C4 (constantes de método correctas).
|
||||||
|
- M4 (extraer `validateOptionalField` en sess).
|
||||||
|
- M5/M11 (limpiar coupling y untrack defensivos).
|
||||||
|
- C5: o implementar MFA verify, o quitarlo del export.
|
||||||
|
- Sub-archivos en `auth/consts.ts`.
|
||||||
|
|
||||||
|
4. **Sprint de tests** (≥1 semana, dependiendo de la profundidad):
|
||||||
|
- Subir cobertura de `auth/test`, `perm/test`, `cach/test`, `fmts/test`
|
||||||
|
y `fend/test` al nivel de `sium/test` y `stor/test`.
|
||||||
|
|
||||||
|
Después de eso el framework estaría sólido y listo para usuarios externos.
|
||||||
|
Antes, el escaparate (lang/logr/sium/sess/stor) no refleja el estado real
|
||||||
|
de los flancos de seguridad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Falsos positivos descartados
|
||||||
|
|
||||||
|
(Reportados por sub-agentes y verificados como incorrectos al leer el código.)
|
||||||
|
|
||||||
|
- **PKCE construcción incorrecta** (`oauth/pkce.ts`). El sub-agente afirmó
|
||||||
|
que `hash(verifier)` no era SHA256/base64url. Verificado: `hashAuthToken`
|
||||||
|
es `base64URL(sha256(token))`, lo cual es exactamente la transformación
|
||||||
|
S256 de RFC 7636. La queja real es C6 (no se valida en callback), no la
|
||||||
|
construcción.
|
||||||
|
- **Refresh rotation no transaccional**. Verificado: `findRefreshTokenForUpdate`
|
||||||
|
+ `rotateRefreshToken` están diseñados para correr atómicamente — el
|
||||||
|
contrato lo asume y un adapter SQL real lo implementa. La queja real es
|
||||||
|
C8 (memory adapter no documentado como inseguro).
|
||||||
|
- **`stateHash as AuthFlowId` permite cualquier hash**. Verificado: el store
|
||||||
|
tiene fallback explícito de búsqueda por stateHash; tipos sufren pero no
|
||||||
|
hay bypass de seguridad. La queja válida es M3 (separar la API).
|
||||||
|
- **Test directories vacíos** (conn, perm, etc.). Verificado: todos tienen
|
||||||
|
≥1 archivo. La queja real es la cobertura desigual, no la ausencia.
|
||||||
|
- **`adoptServer` SSR safety**. El sub-agente sugirió listener leak; el
|
||||||
|
código (engine-session.ts) protege con guards `typeof BroadcastChannel`.
|
||||||
|
- **`storage.adapter.removeItem` con `null`**. Reportado como riesgo; en
|
||||||
|
realidad la API es estándar `Storage` y removeItem(key) sin valor.
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
# Next Steps
|
||||||
|
|
||||||
|
Estado al cierre:
|
||||||
|
|
||||||
|
- Suite unitaria verde: `npm test` -> 103 archivos, 1189 tests.
|
||||||
|
- `fmts` verde: `npx vitest run src/arts/fmts` -> 14 archivos, 39 tests.
|
||||||
|
- No tocar `src/web/routes/temp/` hasta decidir que hacer con esa pagina.
|
||||||
|
- No commitear `.idea/`, `.claude/` ni `.opencode/`.
|
||||||
|
|
||||||
|
Pendiente para manana:
|
||||||
|
|
||||||
|
- Ejecutar una pasada completa sobre `/test/ecosystem` en navegador y corregir cualquier fallo real de integracion.
|
||||||
|
- Revisar la adopcion final del contrato comun `Logger` / diagnostics en todos los modulos, sin acoplar artefactos a `arts/logr`.
|
||||||
|
- Continuar la reduccion de archivos grandes y boilerplate: prioridad `conn`, `auth`, `cach` y cualquier wrapper activo repetitivo.
|
||||||
|
- Ampliar tests de integracion cruzada: `auth + sess + perm + cach + http + stor + fmts + conn + timr + logr`.
|
||||||
|
- Revisar documentacion raiz de `arts`, `aapp`, `auth`, `cach`, `perm`, `conn` y `fmts` para que refleje el estado real del framework.
|
||||||
|
- Decidir que hacer con la pagina temporal que bloquea `npm run check`; mientras tanto, validar con `npm test` y tests focalizados.
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
createCatalogDiagnostics,
|
||||||
|
type DiagnosticCatalog,
|
||||||
|
type DiagnosticEvent,
|
||||||
|
type Diagnostics,
|
||||||
|
type Logger
|
||||||
|
} from '$libs/logr';
|
||||||
|
import {
|
||||||
|
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
|
||||||
|
AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED,
|
||||||
|
AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED,
|
||||||
|
LOGGER_CATEGORY
|
||||||
|
} from './consts.ts';
|
||||||
|
import type { AuthEventName } from '$libs/auth/types';
|
||||||
|
|
||||||
|
export type AuthClientDiagnosticType =
|
||||||
|
(typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS];
|
||||||
|
|
||||||
|
export interface AuthClientDiagnosticMeta {
|
||||||
|
readonly method?: string;
|
||||||
|
readonly reason?: AuthEventName;
|
||||||
|
readonly error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthClientDiagnosticEvent = DiagnosticEvent<
|
||||||
|
AuthClientDiagnosticType,
|
||||||
|
AuthClientDiagnosticMeta
|
||||||
|
>;
|
||||||
|
export type AuthClientDiagnostics = Diagnostics<AuthClientDiagnosticEvent>;
|
||||||
|
|
||||||
|
const AUTH_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog<AuthClientDiagnosticEvent> = {
|
||||||
|
[AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED
|
||||||
|
},
|
||||||
|
[AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAuthClientDiagnostics(logger?: Logger): AuthClientDiagnostics {
|
||||||
|
return createCatalogDiagnostics({
|
||||||
|
logger,
|
||||||
|
defaultCategory: LOGGER_CATEGORY,
|
||||||
|
catalog: AUTH_CLIENT_DIAGNOSTIC_LOGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitAuthClientDiagnostic(
|
||||||
|
diagnostics: AuthClientDiagnostics,
|
||||||
|
type: AuthClientDiagnosticType,
|
||||||
|
meta: AuthClientDiagnosticMeta
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
type,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
createCatalogDiagnostics,
|
||||||
|
type DiagnosticCatalog,
|
||||||
|
type DiagnosticEvent,
|
||||||
|
type Diagnostics,
|
||||||
|
type Logger
|
||||||
|
} from '$libs/logr';
|
||||||
|
import type { CacheKey } from '$libs/cach';
|
||||||
|
import {
|
||||||
|
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
|
||||||
|
CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED,
|
||||||
|
LOGGER_CATEGORY
|
||||||
|
} from './consts.ts';
|
||||||
|
|
||||||
|
export type ActiveCacheDiagnosticType =
|
||||||
|
(typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS)[keyof typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS];
|
||||||
|
|
||||||
|
export interface ActiveCacheDiagnosticMeta {
|
||||||
|
readonly method: string;
|
||||||
|
readonly key?: CacheKey;
|
||||||
|
readonly error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveCacheDiagnosticEvent = DiagnosticEvent<
|
||||||
|
ActiveCacheDiagnosticType,
|
||||||
|
ActiveCacheDiagnosticMeta
|
||||||
|
>;
|
||||||
|
export type ActiveCacheDiagnostics = Diagnostics<ActiveCacheDiagnosticEvent>;
|
||||||
|
|
||||||
|
const ACTIVE_CACHE_DIAGNOSTIC_LOGS: DiagnosticCatalog<ActiveCacheDiagnosticEvent> = {
|
||||||
|
[CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createActiveCacheDiagnostics(logger?: Logger): ActiveCacheDiagnostics {
|
||||||
|
return createCatalogDiagnostics({
|
||||||
|
logger,
|
||||||
|
defaultCategory: LOGGER_CATEGORY,
|
||||||
|
catalog: ACTIVE_CACHE_DIAGNOSTIC_LOGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitActiveCacheDiagnostic(
|
||||||
|
diagnostics: ActiveCacheDiagnostics,
|
||||||
|
type: ActiveCacheDiagnosticType,
|
||||||
|
meta: ActiveCacheDiagnosticMeta
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
type,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import type { TimerScheduler } from '$timr';
|
||||||
|
import {
|
||||||
|
CONNECTION_ACK_REASON_CLOSED,
|
||||||
|
CONNECTION_ACK_REASON_REJECTED,
|
||||||
|
CONNECTION_ACK_REASON_TIMEOUT,
|
||||||
|
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
|
||||||
|
CONNECTION_SEND_REASON_CLOSED,
|
||||||
|
TIMER_KEY_ACK
|
||||||
|
} from './consts.ts';
|
||||||
|
import { timerKey } from './helpers.ts';
|
||||||
|
import type { ConnectionAckResult, ConnectionFrame, ConnectionSendResult } from './types.ts';
|
||||||
|
|
||||||
|
interface PendingAck {
|
||||||
|
readonly timer: string;
|
||||||
|
readonly resolve: (result: ConnectionAckResult<unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionAckRegistry {
|
||||||
|
wait<TResult>(id: string, timeoutMs: number): Promise<ConnectionAckResult<TResult>>;
|
||||||
|
resolve(id: string, result: ConnectionAckResult<unknown>): void;
|
||||||
|
resolveAll(result: ConnectionAckResult<unknown>): void;
|
||||||
|
resolveFromFrame(frame: ConnectionFrame): void;
|
||||||
|
mapSendFailure(result: ConnectionSendResult): ConnectionAckResult<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionAckRegistry(
|
||||||
|
connectionName: string,
|
||||||
|
timers: TimerScheduler
|
||||||
|
): ConnectionAckRegistry {
|
||||||
|
const pending = new Map<string, PendingAck>();
|
||||||
|
|
||||||
|
function resolve(id: string, result: ConnectionAckResult<unknown>): void {
|
||||||
|
const ack = pending.get(id);
|
||||||
|
if (ack === undefined) return;
|
||||||
|
pending.delete(id);
|
||||||
|
timers.cancel(ack.timer);
|
||||||
|
ack.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wait<TResult>(id: string, timeoutMs: number): Promise<ConnectionAckResult<TResult>> {
|
||||||
|
const ackTimer = timerKey(connectionName, TIMER_KEY_ACK, id);
|
||||||
|
return new Promise<ConnectionAckResult<TResult>>((resolveWaiter) => {
|
||||||
|
pending.set(id, {
|
||||||
|
timer: ackTimer,
|
||||||
|
resolve: resolveWaiter as (result: ConnectionAckResult<unknown>) => void
|
||||||
|
});
|
||||||
|
timers.schedule(
|
||||||
|
ackTimer,
|
||||||
|
timeoutMs,
|
||||||
|
() => {
|
||||||
|
resolve(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
resolveAll(result: ConnectionAckResult<unknown>): void {
|
||||||
|
for (const id of [...pending.keys()]) resolve(id, result);
|
||||||
|
},
|
||||||
|
resolveFromFrame(frame: ConnectionFrame): void {
|
||||||
|
if (frame.replyTo === undefined) return;
|
||||||
|
if (frame.error !== undefined) {
|
||||||
|
resolve(frame.replyTo, {
|
||||||
|
ok: false,
|
||||||
|
reason: CONNECTION_ACK_REASON_REJECTED,
|
||||||
|
error: frame.error
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(frame.replyTo, { ok: true, payload: frame.payload });
|
||||||
|
},
|
||||||
|
mapSendFailure(result: ConnectionSendResult): ConnectionAckResult<unknown> {
|
||||||
|
if (result.ok) return { ok: true, payload: undefined };
|
||||||
|
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
|
||||||
|
return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
BROWSER_EVENT_ONLINE,
|
||||||
|
BROWSER_EVENT_VISIBILITY_CHANGE,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS,
|
||||||
|
DEFAULT_RECONNECT_ON_ONLINE,
|
||||||
|
DEFAULT_RECONNECT_ON_VISIBLE,
|
||||||
|
DOCUMENT_VISIBILITY_VISIBLE
|
||||||
|
} from './consts.ts';
|
||||||
|
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
|
||||||
|
import type { ConnectionReconnectOptions } from './types.ts';
|
||||||
|
|
||||||
|
interface BrowserReconnectOptions {
|
||||||
|
readonly disabled: boolean;
|
||||||
|
readonly reconnectOptions?: ConnectionReconnectOptions;
|
||||||
|
readonly shouldReconnect: () => boolean;
|
||||||
|
readonly reconnect: () => void;
|
||||||
|
readonly diagnostics: ConnectionDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireBrowserReconnect(options: BrowserReconnectOptions): () => void {
|
||||||
|
if (options.disabled) return () => {};
|
||||||
|
const detachers: Array<() => void> = [];
|
||||||
|
const reconnectOnOnline =
|
||||||
|
options.reconnectOptions?.reconnectOnOnline ?? DEFAULT_RECONNECT_ON_ONLINE;
|
||||||
|
const reconnectOnVisible =
|
||||||
|
options.reconnectOptions?.reconnectOnVisible ?? DEFAULT_RECONNECT_ON_VISIBLE;
|
||||||
|
const target = globalThis as {
|
||||||
|
addEventListener?: (type: string, listener: () => void) => void;
|
||||||
|
removeEventListener?: (type: string, listener: () => void) => void;
|
||||||
|
document?: {
|
||||||
|
readonly visibilityState?: string;
|
||||||
|
addEventListener?: (type: string, listener: () => void) => void;
|
||||||
|
removeEventListener?: (type: string, listener: () => void) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reconnectOnOnline && target.addEventListener && target.removeEventListener) {
|
||||||
|
const onOnline = (): void => {
|
||||||
|
if (!options.shouldReconnect()) return;
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT,
|
||||||
|
{ trigger: BROWSER_EVENT_ONLINE }
|
||||||
|
);
|
||||||
|
options.reconnect();
|
||||||
|
};
|
||||||
|
target.addEventListener(BROWSER_EVENT_ONLINE, onOnline);
|
||||||
|
detachers.push(() => {
|
||||||
|
target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
reconnectOnVisible &&
|
||||||
|
target.document?.addEventListener &&
|
||||||
|
target.document.removeEventListener
|
||||||
|
) {
|
||||||
|
const onVisible = (): void => {
|
||||||
|
if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return;
|
||||||
|
if (!options.shouldReconnect()) return;
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT,
|
||||||
|
{ trigger: BROWSER_EVENT_VISIBILITY_CHANGE }
|
||||||
|
);
|
||||||
|
options.reconnect();
|
||||||
|
};
|
||||||
|
target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
|
||||||
|
detachers.push(() => {
|
||||||
|
target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const detach of detachers) detach();
|
||||||
|
detachers.length = 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts';
|
||||||
|
import type {
|
||||||
|
Connection,
|
||||||
|
ConnectionChannel,
|
||||||
|
ConnectionChannelMap,
|
||||||
|
ConnectionChannelOptions,
|
||||||
|
ConnectionEventMap,
|
||||||
|
ConnectionFrame,
|
||||||
|
ConnectionMessageMeta
|
||||||
|
} from './types.ts';
|
||||||
|
|
||||||
|
export interface ConnectionChannelRegistry<TChannels extends ConnectionChannelMap> {
|
||||||
|
receive(topic: string, frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
|
||||||
|
getOrCreate<TEvents extends ConnectionEventMap = ConnectionEventMap>(
|
||||||
|
name: string,
|
||||||
|
options: ConnectionChannelOptions,
|
||||||
|
connection: Connection<Record<string, TEvents>>
|
||||||
|
): ConnectionChannel<TEvents>;
|
||||||
|
channels(): readonly ConnectionChannel[];
|
||||||
|
has(name: string): boolean;
|
||||||
|
leave(name: string): Promise<void>;
|
||||||
|
joinConfigured(
|
||||||
|
reconnect: boolean,
|
||||||
|
configured: Readonly<Record<string, ConnectionChannelOptions | undefined>>,
|
||||||
|
connection: Connection<TChannels>
|
||||||
|
): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionChannelRegistry<TChannels extends ConnectionChannelMap>(
|
||||||
|
reportListenerError: (event: string, error: unknown) => void
|
||||||
|
): ConnectionChannelRegistry<TChannels> {
|
||||||
|
const channels = new Map<string, InternalConnectionChannel>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
receive(topic, frame, meta): void {
|
||||||
|
channels.get(topic)?.receive(frame, meta);
|
||||||
|
},
|
||||||
|
getOrCreate<TEvents extends ConnectionEventMap = ConnectionEventMap>(
|
||||||
|
channelName: string,
|
||||||
|
channelOptions: ConnectionChannelOptions,
|
||||||
|
connection: Connection<Record<string, TEvents>>
|
||||||
|
): ConnectionChannel<TEvents> {
|
||||||
|
const existing = channels.get(channelName);
|
||||||
|
if (existing !== undefined) return existing as ConnectionChannel<TEvents>;
|
||||||
|
const created = createConnectionChannel<TEvents>(channelName, channelOptions, {
|
||||||
|
connection,
|
||||||
|
reportListenerError
|
||||||
|
});
|
||||||
|
channels.set(channelName, created as InternalConnectionChannel);
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
channels(): readonly ConnectionChannel[] {
|
||||||
|
return [...channels.values()];
|
||||||
|
},
|
||||||
|
has(name): boolean {
|
||||||
|
return channels.has(name);
|
||||||
|
},
|
||||||
|
async leave(name): Promise<void> {
|
||||||
|
await channels.get(name)?.leave();
|
||||||
|
},
|
||||||
|
async joinConfigured(reconnect, configured, connection): Promise<void> {
|
||||||
|
const entries = (configured ?? {}) as Record<string, ConnectionChannelOptions | undefined>;
|
||||||
|
for (const channelName of Object.keys(entries)) {
|
||||||
|
const configuredOptions = entries[channelName] ?? {};
|
||||||
|
const current = this.getOrCreate(
|
||||||
|
channelName,
|
||||||
|
configuredOptions,
|
||||||
|
connection as unknown as Connection<Record<string, ConnectionEventMap>>
|
||||||
|
) as InternalConnectionChannel;
|
||||||
|
if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) {
|
||||||
|
await current.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!reconnect) return;
|
||||||
|
for (const current of channels.values()) {
|
||||||
|
if (current.shouldRejoin) await current.rejoin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose(): void {
|
||||||
|
for (const current of channels.values()) current.dispose();
|
||||||
|
channels.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_ACK_REASON_CLOSED,
|
||||||
|
CONNECTION_ACK_REASON_REJECTED,
|
||||||
|
CONNECTION_ACK_REASON_TIMEOUT,
|
||||||
|
CONNECTION_AUTH_REASON_CLOSED,
|
||||||
|
CONNECTION_AUTH_REASON_NO_PROVIDER,
|
||||||
|
CONNECTION_AUTH_REASON_REJECTED,
|
||||||
|
CONNECTION_AUTH_REASON_TIMEOUT,
|
||||||
|
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
|
||||||
|
CONNECTION_FRAME_TYPE_AUTH
|
||||||
|
} from './consts.ts';
|
||||||
|
import type {
|
||||||
|
ConnectionAckResult,
|
||||||
|
ConnectionAuthPayload,
|
||||||
|
ConnectionAuthResult,
|
||||||
|
ConnectionOptions
|
||||||
|
} from './types.ts';
|
||||||
|
|
||||||
|
export type ConnectionAuthProvider = () =>
|
||||||
|
| ConnectionAuthPayload
|
||||||
|
| null
|
||||||
|
| Promise<ConnectionAuthPayload | null>;
|
||||||
|
|
||||||
|
export interface ResolvedConnectionAuth {
|
||||||
|
readonly provider: ConnectionAuthProvider | null;
|
||||||
|
readonly authType: string;
|
||||||
|
readonly timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConnectionAuth(auth: ConnectionOptions['auth']): ResolvedConnectionAuth {
|
||||||
|
if (typeof auth === 'function') {
|
||||||
|
return { provider: auth, authType: CONNECTION_FRAME_TYPE_AUTH };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: auth?.getAuth ?? null,
|
||||||
|
authType: auth?.authType ?? CONNECTION_FRAME_TYPE_AUTH,
|
||||||
|
timeoutMs: auth?.timeoutMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missingConnectionAuthProvider(): ConnectionAuthResult {
|
||||||
|
return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapConnectionAckToAuthResult(
|
||||||
|
result: ConnectionAckResult<unknown>
|
||||||
|
): ConnectionAuthResult {
|
||||||
|
if (result.ok) return { ok: true };
|
||||||
|
if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) {
|
||||||
|
return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error };
|
||||||
|
}
|
||||||
|
if (result.reason === CONNECTION_ACK_REASON_CLOSED) {
|
||||||
|
return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error };
|
||||||
|
}
|
||||||
|
if (result.reason === CONNECTION_ACK_REASON_REJECTED) {
|
||||||
|
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error };
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { CONNECTION_DIAGNOSTIC_EVENTS } from './consts.ts';
|
||||||
|
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
|
||||||
|
import type { ConnectionFrame, ConnectionMessageMeta, ConnectionStateChange } from './types.ts';
|
||||||
|
|
||||||
|
type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void;
|
||||||
|
type StateListener = (change: ConnectionStateChange) => void;
|
||||||
|
|
||||||
|
interface ConnectionEventBusOptions {
|
||||||
|
readonly diagnostics: ConnectionDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionEventBus {
|
||||||
|
emitState(change: ConnectionStateChange): void;
|
||||||
|
emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
|
||||||
|
onState(listener: StateListener): () => void;
|
||||||
|
onAny(listener: GlobalListener): () => void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionEventBus(options: ConnectionEventBusOptions): ConnectionEventBus {
|
||||||
|
const stateListeners = new Set<StateListener>();
|
||||||
|
const globalListeners = new Set<GlobalListener>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
emitState(change): void {
|
||||||
|
for (const listener of [...stateListeners]) {
|
||||||
|
try {
|
||||||
|
listener(change);
|
||||||
|
} catch (err) {
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
|
||||||
|
{ error: err, event: change.to }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emitGlobal(frame, meta): void {
|
||||||
|
for (const listener of [...globalListeners]) {
|
||||||
|
try {
|
||||||
|
listener(frame, meta);
|
||||||
|
} catch (err) {
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
|
||||||
|
{ error: err, event: frame.type }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onState(listener): () => void {
|
||||||
|
stateListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
stateListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onAny(listener): () => void {
|
||||||
|
globalListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
globalListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
stateListeners.clear();
|
||||||
|
globalListeners.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_STATE_CLOSED,
|
||||||
|
CONNECTION_STATE_FAILED,
|
||||||
|
CONNECTION_STATE_IDLE,
|
||||||
|
CONNECTION_STATE_OPEN
|
||||||
|
} from './consts.ts';
|
||||||
|
import type { ConnectionState, ConnectionStateChange } from './types.ts';
|
||||||
|
|
||||||
|
export interface ConnectionStateTracker {
|
||||||
|
readonly state: ConnectionState;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly error: unknown | null;
|
||||||
|
readonly openedAt: number | null;
|
||||||
|
readonly closedAt: number | null;
|
||||||
|
readonly lastMessageAt: number | null;
|
||||||
|
readonly reconnectAttempt: number;
|
||||||
|
setError(error: unknown | null): void;
|
||||||
|
setReconnectAttempt(attempt: number): void;
|
||||||
|
touchMessage(): number;
|
||||||
|
isClosedLike(): boolean;
|
||||||
|
transition(to: ConnectionState, error?: unknown): boolean;
|
||||||
|
markOpen(): boolean;
|
||||||
|
markClosed(to: ConnectionState, error?: unknown, beforeEmit?: () => void): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionStateTracker(input: {
|
||||||
|
readonly name: string;
|
||||||
|
readonly now: () => number;
|
||||||
|
readonly emitState: (change: ConnectionStateChange) => void;
|
||||||
|
}): ConnectionStateTracker {
|
||||||
|
let state: ConnectionState = CONNECTION_STATE_IDLE;
|
||||||
|
let generation = 0;
|
||||||
|
let error: unknown | null = null;
|
||||||
|
let openedAt: number | null = null;
|
||||||
|
let closedAt: number | null = null;
|
||||||
|
let lastMessageAt: number | null = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
|
||||||
|
function transition(to: ConnectionState, changeError?: unknown): boolean {
|
||||||
|
if (state === to && changeError === undefined) return false;
|
||||||
|
const from = state;
|
||||||
|
state = to;
|
||||||
|
input.emitState({
|
||||||
|
connection: input.name,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
generation,
|
||||||
|
error: changeError,
|
||||||
|
at: input.now()
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
get generation() {
|
||||||
|
return generation;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get openedAt() {
|
||||||
|
return openedAt;
|
||||||
|
},
|
||||||
|
get closedAt() {
|
||||||
|
return closedAt;
|
||||||
|
},
|
||||||
|
get lastMessageAt() {
|
||||||
|
return lastMessageAt;
|
||||||
|
},
|
||||||
|
get reconnectAttempt() {
|
||||||
|
return reconnectAttempt;
|
||||||
|
},
|
||||||
|
setError(nextError) {
|
||||||
|
error = nextError;
|
||||||
|
},
|
||||||
|
setReconnectAttempt(attempt) {
|
||||||
|
reconnectAttempt = attempt;
|
||||||
|
},
|
||||||
|
touchMessage() {
|
||||||
|
lastMessageAt = input.now();
|
||||||
|
return lastMessageAt;
|
||||||
|
},
|
||||||
|
isClosedLike() {
|
||||||
|
return (
|
||||||
|
state === CONNECTION_STATE_CLOSED ||
|
||||||
|
state === CONNECTION_STATE_FAILED ||
|
||||||
|
state === CONNECTION_STATE_IDLE
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transition,
|
||||||
|
markOpen() {
|
||||||
|
if (state === CONNECTION_STATE_OPEN) return false;
|
||||||
|
generation += 1;
|
||||||
|
error = null;
|
||||||
|
openedAt = input.now();
|
||||||
|
closedAt = null;
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
return transition(CONNECTION_STATE_OPEN);
|
||||||
|
},
|
||||||
|
markClosed(to, closeError, beforeEmit) {
|
||||||
|
if (state === to) return false;
|
||||||
|
closedAt = input.now();
|
||||||
|
beforeEmit?.();
|
||||||
|
return transition(to, closeError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import type { TimerScheduler } from '$timr';
|
||||||
|
import { timerKey } from './helpers.ts';
|
||||||
|
|
||||||
|
export interface ConnectionTimerControls {
|
||||||
|
cancel(kind: string, id?: string): void;
|
||||||
|
schedule(kind: string, delayMs: number, task: () => void | Promise<void>, id?: string): void;
|
||||||
|
interval(kind: string, everyMs: number, task: () => void, id?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionTimerControls(
|
||||||
|
name: string,
|
||||||
|
timers: TimerScheduler
|
||||||
|
): ConnectionTimerControls {
|
||||||
|
return {
|
||||||
|
cancel(kind, id) {
|
||||||
|
timers.cancel(timerKey(name, kind, id));
|
||||||
|
},
|
||||||
|
schedule(kind, delayMs, task, id) {
|
||||||
|
timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true });
|
||||||
|
},
|
||||||
|
interval(kind, everyMs, task, id) {
|
||||||
|
timers.interval(timerKey(name, kind, id), everyMs, task, {
|
||||||
|
replace: true,
|
||||||
|
awaitTask: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
createCatalogDiagnostics,
|
||||||
|
type DiagnosticCatalog,
|
||||||
|
type DiagnosticEvent,
|
||||||
|
type Diagnostics
|
||||||
|
} from '$libs/logr';
|
||||||
|
import {
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS,
|
||||||
|
CONNECTION_EVENT_MESSAGE,
|
||||||
|
LOGGER_CATEGORY,
|
||||||
|
LOG_MSG_AUTH_FAILED,
|
||||||
|
LOG_MSG_BROWSER_RECONNECT,
|
||||||
|
LOG_MSG_CONNECT_FAILED,
|
||||||
|
LOG_MSG_FRAME_DECODE_FAILED,
|
||||||
|
LOG_MSG_FRAME_ENCODE_FAILED,
|
||||||
|
LOG_MSG_HEARTBEAT_TIMEOUT,
|
||||||
|
LOG_MSG_REAUTH_FAILED,
|
||||||
|
LOG_MSG_RECONNECT_EXHAUSTED,
|
||||||
|
LOG_MSG_SEND_FAILED,
|
||||||
|
LOG_MSG_SESSION_EXPIRED,
|
||||||
|
LOG_MSG_SESSION_REFRESHED,
|
||||||
|
LOG_MSG_SESSION_REVOKED,
|
||||||
|
LOG_MSG_TRANSPORT_ERROR
|
||||||
|
} from './consts.ts';
|
||||||
|
import { listenerThrewMessage } from './helpers.ts';
|
||||||
|
import type { Logger } from '$libs/logr';
|
||||||
|
|
||||||
|
export type ConnectionDiagnosticType =
|
||||||
|
(typeof CONNECTION_DIAGNOSTIC_EVENTS)[keyof typeof CONNECTION_DIAGNOSTIC_EVENTS];
|
||||||
|
|
||||||
|
export type ConnectionDiagnosticEvent = DiagnosticEvent<ConnectionDiagnosticType>;
|
||||||
|
export type ConnectionDiagnostics = Diagnostics<ConnectionDiagnosticEvent>;
|
||||||
|
|
||||||
|
const CONNECTION_DIAGNOSTIC_LOGS: DiagnosticCatalog<ConnectionDiagnosticEvent> = {
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.AUTH_FAILED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LOG_MSG_AUTH_FAILED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT]: {
|
||||||
|
level: LogLevel.DEBUG,
|
||||||
|
message: LOG_MSG_BROWSER_RECONNECT
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.CONNECT_FAILED]: {
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LOG_MSG_CONNECT_FAILED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.FRAME_DECODE_FAILED]: {
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LOG_MSG_FRAME_DECODE_FAILED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.FRAME_ENCODE_FAILED]: {
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LOG_MSG_FRAME_ENCODE_FAILED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.HEARTBEAT_TIMEOUT]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LOG_MSG_HEARTBEAT_TIMEOUT
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW]: (event) => ({
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: listenerThrewMessage(listenerEventName(event.meta))
|
||||||
|
}),
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.REAUTH_FAILED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LOG_MSG_REAUTH_FAILED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.RECONNECT_EXHAUSTED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LOG_MSG_RECONNECT_EXHAUSTED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.SEND_FAILED]: {
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LOG_MSG_SEND_FAILED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.SESSION_EXPIRED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LOG_MSG_SESSION_EXPIRED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REFRESHED]: {
|
||||||
|
level: LogLevel.DEBUG,
|
||||||
|
message: LOG_MSG_SESSION_REFRESHED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REVOKED]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LOG_MSG_SESSION_REVOKED
|
||||||
|
},
|
||||||
|
[CONNECTION_DIAGNOSTIC_EVENTS.TRANSPORT_ERROR]: {
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LOG_MSG_TRANSPORT_ERROR
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function listenerEventName(meta: unknown): string {
|
||||||
|
if (typeof meta !== 'object' || meta === null) return CONNECTION_EVENT_MESSAGE;
|
||||||
|
if (!('event' in meta)) return CONNECTION_EVENT_MESSAGE;
|
||||||
|
const event = meta.event;
|
||||||
|
return typeof event === 'string' && event.length > 0 ? event : CONNECTION_EVENT_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionDiagnostics(input: {
|
||||||
|
readonly logger?: Logger;
|
||||||
|
readonly scope: string;
|
||||||
|
}): ConnectionDiagnostics {
|
||||||
|
return createCatalogDiagnostics({
|
||||||
|
logger: input.logger,
|
||||||
|
defaultCategory: input.scope,
|
||||||
|
catalog: CONNECTION_DIAGNOSTIC_LOGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitConnectionDiagnostic(
|
||||||
|
diagnostics: ConnectionDiagnostics,
|
||||||
|
type: ConnectionDiagnosticType,
|
||||||
|
meta?: unknown
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
type,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitScopedConnectionDiagnostic(
|
||||||
|
diagnostics: ConnectionDiagnostics,
|
||||||
|
scope: string,
|
||||||
|
type: ConnectionDiagnosticType,
|
||||||
|
meta?: unknown
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
scope,
|
||||||
|
type,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_BUFFER_POLICY_BUFFER,
|
||||||
|
CONNECTION_BUFFER_POLICY_DROP,
|
||||||
|
CONNECTION_SEND_REASON_BUFFER_FULL,
|
||||||
|
DEFAULT_BUFFER_MAX_BYTES,
|
||||||
|
DEFAULT_BUFFER_MAX_MESSAGES
|
||||||
|
} from './consts.ts';
|
||||||
|
import type { ConnectionBufferOptions, ConnectionFrame, ConnectionSendResult } from './types.ts';
|
||||||
|
|
||||||
|
export interface ConnectionFrameBuffer {
|
||||||
|
readonly maxBytes: number;
|
||||||
|
readonly length: number;
|
||||||
|
canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean;
|
||||||
|
shouldDropClosedFrame(): boolean;
|
||||||
|
push(frame: ConnectionFrame): ConnectionSendResult;
|
||||||
|
shift(): ConnectionFrame | undefined;
|
||||||
|
unshift(frame: ConnectionFrame): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionFrameBuffer(
|
||||||
|
options: ConnectionBufferOptions = {}
|
||||||
|
): ConnectionFrameBuffer {
|
||||||
|
const frames: ConnectionFrame[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
get maxBytes() {
|
||||||
|
return options.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES;
|
||||||
|
},
|
||||||
|
get length() {
|
||||||
|
return frames.length;
|
||||||
|
},
|
||||||
|
canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean {
|
||||||
|
if (!allowBuffer) return false;
|
||||||
|
if (frame.ack === true) return false;
|
||||||
|
return options.policy === CONNECTION_BUFFER_POLICY_BUFFER;
|
||||||
|
},
|
||||||
|
shouldDropClosedFrame(): boolean {
|
||||||
|
return options.policy === CONNECTION_BUFFER_POLICY_DROP;
|
||||||
|
},
|
||||||
|
push(frame: ConnectionFrame): ConnectionSendResult {
|
||||||
|
const maxMessages = options.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES;
|
||||||
|
if (frames.length >= maxMessages) {
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
|
||||||
|
}
|
||||||
|
frames.push(frame);
|
||||||
|
return { ok: true, id: frame.id };
|
||||||
|
},
|
||||||
|
shift() {
|
||||||
|
return frames.shift();
|
||||||
|
},
|
||||||
|
unshift(frame: ConnectionFrame): void {
|
||||||
|
frames.unshift(frame);
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
frames.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS,
|
||||||
|
CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT,
|
||||||
|
CONNECTION_FRAME_TYPE_PING,
|
||||||
|
DEFAULT_HEARTBEAT_ENABLED,
|
||||||
|
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||||||
|
DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
||||||
|
TIMER_KEY_HEARTBEAT,
|
||||||
|
TIMER_KEY_HEARTBEAT_TIMEOUT
|
||||||
|
} from './consts.ts';
|
||||||
|
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
|
||||||
|
import { createFrame } from './serializer.ts';
|
||||||
|
import type { ConnectionHeartbeatOptions, ConnectionSendResult } from './types.ts';
|
||||||
|
|
||||||
|
interface ConnectionHeartbeatRuntime {
|
||||||
|
readonly heartbeatOptions?: ConnectionHeartbeatOptions | false;
|
||||||
|
isConnected(): boolean;
|
||||||
|
sendFrame(
|
||||||
|
frame: ReturnType<typeof createFrame>,
|
||||||
|
allowBuffer: boolean
|
||||||
|
): Promise<ConnectionSendResult>;
|
||||||
|
scheduleInterval(kind: string, everyMs: number, task: () => void, id?: string): void;
|
||||||
|
scheduleTimer(kind: string, delayMs: number, task: () => void | Promise<void>, id?: string): void;
|
||||||
|
cancelTimer(kind: string, id?: string): void;
|
||||||
|
closeTransport(reason: string): void;
|
||||||
|
readonly diagnostics: ConnectionDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionHeartbeat {
|
||||||
|
received(): void;
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionHeartbeat(
|
||||||
|
runtime: ConnectionHeartbeatRuntime
|
||||||
|
): ConnectionHeartbeat {
|
||||||
|
async function sendHeartbeat(): Promise<void> {
|
||||||
|
if (!runtime.isConnected()) return;
|
||||||
|
const pingType =
|
||||||
|
runtime.heartbeatOptions === false ? undefined : runtime.heartbeatOptions?.pingType;
|
||||||
|
const result = await runtime.sendFrame(
|
||||||
|
createFrame({
|
||||||
|
type: pingType ?? CONNECTION_FRAME_TYPE_PING,
|
||||||
|
payload: undefined
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (!result.ok) return;
|
||||||
|
const timeoutMs =
|
||||||
|
runtime.heartbeatOptions === false
|
||||||
|
? DEFAULT_HEARTBEAT_TIMEOUT_MS
|
||||||
|
: (runtime.heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS);
|
||||||
|
runtime.scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => {
|
||||||
|
emitConnectionDiagnostic(runtime.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.HEARTBEAT_TIMEOUT);
|
||||||
|
runtime.closeTransport(CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
received(): void {
|
||||||
|
runtime.cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
|
||||||
|
},
|
||||||
|
start(): void {
|
||||||
|
if (runtime.heartbeatOptions === false) return;
|
||||||
|
if ((runtime.heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return;
|
||||||
|
const intervalMs = runtime.heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||||
|
runtime.scheduleInterval(TIMER_KEY_HEARTBEAT, intervalMs, () => {
|
||||||
|
void sendHeartbeat();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stop(): void {
|
||||||
|
runtime.cancelTimer(TIMER_KEY_HEARTBEAT);
|
||||||
|
runtime.cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { computeBackoffDelay } from '$libs/timers';
|
||||||
|
import {
|
||||||
|
DEFAULT_RECONNECT_ENABLED,
|
||||||
|
DEFAULT_RECONNECT_FACTOR,
|
||||||
|
DEFAULT_RECONNECT_JITTER_MS,
|
||||||
|
DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||||
|
DEFAULT_RECONNECT_MIN_DELAY_MS
|
||||||
|
} from './consts.ts';
|
||||||
|
import type { ConnectionOptions, ConnectionReconnectOptions } from './types.ts';
|
||||||
|
|
||||||
|
export type ConnectionReconnectPlan =
|
||||||
|
| {
|
||||||
|
readonly ok: true;
|
||||||
|
readonly attempt: number;
|
||||||
|
readonly delayMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly reconnectAttempt: number;
|
||||||
|
readonly maxAttempts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConnectionReconnectPolicy {
|
||||||
|
readonly disabled: boolean;
|
||||||
|
readonly options?: ConnectionReconnectOptions;
|
||||||
|
isEnabled(input: { readonly disposed: boolean; readonly intentionalClose: boolean }): boolean;
|
||||||
|
next(currentAttempt: number): ConnectionReconnectPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionReconnectPolicy(
|
||||||
|
reconnect: ConnectionOptions['reconnect']
|
||||||
|
): ConnectionReconnectPolicy {
|
||||||
|
const disabled = reconnect === false;
|
||||||
|
const options = disabled ? undefined : reconnect;
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabled,
|
||||||
|
options,
|
||||||
|
isEnabled(input) {
|
||||||
|
if (input.disposed || input.intentionalClose) return false;
|
||||||
|
if (disabled) return false;
|
||||||
|
return options?.enabled ?? DEFAULT_RECONNECT_ENABLED;
|
||||||
|
},
|
||||||
|
next(currentAttempt) {
|
||||||
|
const maxAttempts = options?.maxAttempts;
|
||||||
|
if (maxAttempts !== undefined && currentAttempt >= maxAttempts) {
|
||||||
|
return { ok: false, reconnectAttempt: currentAttempt, maxAttempts };
|
||||||
|
}
|
||||||
|
const attempt = currentAttempt + 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
attempt,
|
||||||
|
delayMs: computeBackoffDelay(attempt - 1, {
|
||||||
|
minDelayMs: options?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS,
|
||||||
|
maxDelayMs: options?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||||
|
factor: options?.factor ?? DEFAULT_RECONNECT_FACTOR,
|
||||||
|
jitterMs: options?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { isPromiseLike } from '$libs/standard-schema';
|
||||||
|
import {
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS,
|
||||||
|
CONNECTION_SEND_REASON_BUFFER_FULL,
|
||||||
|
CONNECTION_SEND_REASON_CLOSED,
|
||||||
|
CONNECTION_SEND_REASON_INVALID_FRAME,
|
||||||
|
CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED,
|
||||||
|
CONNECTION_SEND_REASON_SERIALIZE_FAILED,
|
||||||
|
CONNECTION_SEND_REASON_TRANSPORT_ERROR
|
||||||
|
} from './consts.ts';
|
||||||
|
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
|
||||||
|
import type { ConnectionFrameBuffer } from './frame-buffer.ts';
|
||||||
|
import { createFrame } from './serializer.ts';
|
||||||
|
import type {
|
||||||
|
ConnectionFrame,
|
||||||
|
ConnectionSendResult,
|
||||||
|
ConnectionSerializer,
|
||||||
|
ConnectionTransport
|
||||||
|
} from './types.ts';
|
||||||
|
|
||||||
|
interface ConnectionSenderRuntime {
|
||||||
|
readonly serializer: ConnectionSerializer;
|
||||||
|
readonly frameBuffer: ConnectionFrameBuffer;
|
||||||
|
isConnected(): boolean;
|
||||||
|
getTransport(): ConnectionTransport | null;
|
||||||
|
setError(error: unknown): void;
|
||||||
|
readonly diagnostics: ConnectionDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionSender {
|
||||||
|
sendFrame(frame: ConnectionFrame, allowBuffer: boolean): Promise<ConnectionSendResult>;
|
||||||
|
flushBuffer(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionSender(runtime: ConnectionSenderRuntime): ConnectionSender {
|
||||||
|
async function sendFrame(
|
||||||
|
frame: ConnectionFrame,
|
||||||
|
allowBuffer: boolean
|
||||||
|
): Promise<ConnectionSendResult> {
|
||||||
|
try {
|
||||||
|
createFrame(frame);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtime.isConnected()) {
|
||||||
|
if (runtime.frameBuffer.canBuffer(frame, allowBuffer)) return runtime.frameBuffer.push(frame);
|
||||||
|
if (runtime.frameBuffer.shouldDropClosedFrame()) return { ok: true, id: frame.id };
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTransport = runtime.getTransport();
|
||||||
|
if (currentTransport === null || !currentTransport.canSend) {
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED };
|
||||||
|
}
|
||||||
|
if (currentTransport.bufferedAmount > runtime.frameBuffer.maxBytes) {
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded: string | ArrayBuffer;
|
||||||
|
try {
|
||||||
|
encoded = runtime.serializer.encode(frame);
|
||||||
|
} catch (err) {
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
runtime.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.FRAME_ENCODE_FAILED,
|
||||||
|
{ error: err }
|
||||||
|
);
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const maybe = currentTransport.send(encoded);
|
||||||
|
if (isPromiseLike(maybe)) await maybe;
|
||||||
|
return { ok: true, id: frame.id };
|
||||||
|
} catch (err) {
|
||||||
|
runtime.setError(err);
|
||||||
|
emitConnectionDiagnostic(runtime.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.SEND_FAILED, {
|
||||||
|
error: err
|
||||||
|
});
|
||||||
|
return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendFrame,
|
||||||
|
async flushBuffer(): Promise<void> {
|
||||||
|
while (runtime.isConnected() && runtime.frameBuffer.length > 0) {
|
||||||
|
const frame = runtime.frameBuffer.shift();
|
||||||
|
if (frame === undefined) return;
|
||||||
|
const result = await sendFrame(frame, false);
|
||||||
|
if (!result.ok) {
|
||||||
|
runtime.frameBuffer.unshift(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS,
|
||||||
|
CONNECTION_CLOSE_REASON_SESSION_EXPIRED,
|
||||||
|
SESSION_EVENT_EXPIRED,
|
||||||
|
SESSION_EVENT_REFRESHED,
|
||||||
|
SESSION_EVENT_REVOKED
|
||||||
|
} from './consts.ts';
|
||||||
|
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
|
||||||
|
import type {
|
||||||
|
ConnectionAuthResult,
|
||||||
|
ConnectionSessionOptions,
|
||||||
|
ConnectionSessionSource
|
||||||
|
} from './types.ts';
|
||||||
|
|
||||||
|
interface ConnectionSessionWiringOptions {
|
||||||
|
readonly sessionOptions?: false | ConnectionSessionOptions;
|
||||||
|
readonly source?: ConnectionSessionSource;
|
||||||
|
readonly reauthenticate: () => Promise<ConnectionAuthResult>;
|
||||||
|
readonly disconnect: (reason: string) => void;
|
||||||
|
readonly diagnostics: ConnectionDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireConnectionSession(options: ConnectionSessionWiringOptions): () => void {
|
||||||
|
const sessionOptions = options.sessionOptions;
|
||||||
|
if (
|
||||||
|
sessionOptions === undefined ||
|
||||||
|
sessionOptions === false ||
|
||||||
|
sessionOptions.enabled === false
|
||||||
|
) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
if (options.source === undefined) return () => {};
|
||||||
|
|
||||||
|
return options.source.onChange((change) => {
|
||||||
|
if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) {
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REFRESHED,
|
||||||
|
{ event: change.event }
|
||||||
|
);
|
||||||
|
void options.reauthenticate().then((result) => {
|
||||||
|
if (!result.ok) {
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
CONNECTION_DIAGNOSTIC_EVENTS.REAUTH_FAILED,
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) &&
|
||||||
|
sessionOptions.disconnectOnExpire !== false
|
||||||
|
) {
|
||||||
|
emitConnectionDiagnostic(
|
||||||
|
options.diagnostics,
|
||||||
|
change.event === SESSION_EVENT_EXPIRED
|
||||||
|
? CONNECTION_DIAGNOSTIC_EVENTS.SESSION_EXPIRED
|
||||||
|
: CONNECTION_DIAGNOSTIC_EVENTS.SESSION_REVOKED,
|
||||||
|
{ event: change.event }
|
||||||
|
);
|
||||||
|
options.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CONNECTION_STATE_CLOSED,
|
||||||
|
CONNECTION_STATE_IDLE,
|
||||||
|
CONNECTION_STATE_OPEN,
|
||||||
|
createMockTransport
|
||||||
|
} from '../index.ts';
|
||||||
|
import { createActiveConnections } from '../active-connections.svelte.ts';
|
||||||
|
|
||||||
|
describe('createActiveConnections', () => {
|
||||||
|
it('tracks active names and connection state buckets', async () => {
|
||||||
|
const Connections = createActiveConnections();
|
||||||
|
const Main = Connections.createConnection('main', {
|
||||||
|
transport: createMockTransport(),
|
||||||
|
heartbeat: false,
|
||||||
|
reconnect: false
|
||||||
|
});
|
||||||
|
Connections.createConnection('chat', {
|
||||||
|
transport: createMockTransport(),
|
||||||
|
heartbeat: false,
|
||||||
|
reconnect: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Connections.size).toBe(2);
|
||||||
|
expect(Connections.activeNames).toEqual(['main', 'chat']);
|
||||||
|
expect(Connections.states).toEqual({
|
||||||
|
main: CONNECTION_STATE_IDLE,
|
||||||
|
chat: CONNECTION_STATE_IDLE
|
||||||
|
});
|
||||||
|
expect(Connections.allConnected).toBe(false);
|
||||||
|
expect(Connections.anyConnected).toBe(false);
|
||||||
|
|
||||||
|
await Main.connect();
|
||||||
|
|
||||||
|
expect(Connections.connectedNames).toEqual(['main']);
|
||||||
|
expect(Connections.anyConnected).toBe(true);
|
||||||
|
expect(Connections.allConnected).toBe(false);
|
||||||
|
|
||||||
|
Main.disconnect();
|
||||||
|
|
||||||
|
expect(Connections.closedNames).toEqual(['main']);
|
||||||
|
expect(Connections.states.main).toBe(CONNECTION_STATE_CLOSED);
|
||||||
|
|
||||||
|
Connections.dispose();
|
||||||
|
expect(Connections.size).toBe(0);
|
||||||
|
expect(Connections.activeNames).toEqual([]);
|
||||||
|
expect(Connections.states).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps active state updated when operations go through the active root', async () => {
|
||||||
|
const Connections = createActiveConnections();
|
||||||
|
Connections.createConnection('main', {
|
||||||
|
transport: createMockTransport(),
|
||||||
|
heartbeat: false,
|
||||||
|
reconnect: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await Connections.openConnection('main');
|
||||||
|
expect(Connections.states.main).toBe(CONNECTION_STATE_OPEN);
|
||||||
|
expect(Connections.allConnected).toBe(true);
|
||||||
|
|
||||||
|
Connections.closeConnection('main');
|
||||||
|
expect(Connections.states.main).toBe(CONNECTION_STATE_CLOSED);
|
||||||
|
|
||||||
|
Connections.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CONNECTION_STATE_CLOSED,
|
||||||
|
CONNECTION_STATE_OPEN,
|
||||||
|
CONNECTION_STATE_RECONNECTING
|
||||||
|
} from '../consts.ts';
|
||||||
|
import { createConnectionStateTracker } from '../connection-state.ts';
|
||||||
|
import type { ConnectionStateChange } from '../types.ts';
|
||||||
|
|
||||||
|
describe('ConnectionStateTracker', () => {
|
||||||
|
it('tracks open/close timestamps and emits state changes', () => {
|
||||||
|
let now = 10;
|
||||||
|
const changes: ConnectionStateChange[] = [];
|
||||||
|
const tracker = createConnectionStateTracker({
|
||||||
|
name: 'main',
|
||||||
|
now: () => now,
|
||||||
|
emitState: (change) => {
|
||||||
|
changes.push(change);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tracker.markOpen()).toBe(true);
|
||||||
|
expect(tracker.state).toBe(CONNECTION_STATE_OPEN);
|
||||||
|
expect(tracker.generation).toBe(1);
|
||||||
|
expect(tracker.openedAt).toBe(10);
|
||||||
|
|
||||||
|
now = 20;
|
||||||
|
expect(tracker.markClosed(CONNECTION_STATE_CLOSED)).toBe(true);
|
||||||
|
expect(tracker.state).toBe(CONNECTION_STATE_CLOSED);
|
||||||
|
expect(tracker.closedAt).toBe(20);
|
||||||
|
expect(changes.map((change) => change.to)).toEqual([
|
||||||
|
CONNECTION_STATE_OPEN,
|
||||||
|
CONNECTION_STATE_CLOSED
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps duplicate close notifications idempotent', () => {
|
||||||
|
let now = 100;
|
||||||
|
const tracker = createConnectionStateTracker({
|
||||||
|
name: 'main',
|
||||||
|
now: () => now,
|
||||||
|
emitState: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.markOpen();
|
||||||
|
tracker.markClosed(CONNECTION_STATE_CLOSED);
|
||||||
|
|
||||||
|
now = 200;
|
||||||
|
expect(tracker.markClosed(CONNECTION_STATE_CLOSED)).toBe(false);
|
||||||
|
expect(tracker.closedAt).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs cleanup before emitting close transitions', () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const tracker = createConnectionStateTracker({
|
||||||
|
name: 'main',
|
||||||
|
now: () => 1,
|
||||||
|
emitState: () => {
|
||||||
|
order.push('emit');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.markOpen();
|
||||||
|
order.length = 0;
|
||||||
|
tracker.markClosed(CONNECTION_STATE_RECONNECTING, undefined, () => {
|
||||||
|
order.push('cleanup');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order).toEqual(['cleanup', 'emit']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ConnWebSocketUnavailableError,
|
||||||
|
createWebSocketTransport,
|
||||||
|
isConnWebSocketUnavailableError
|
||||||
|
} from '../index.ts';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebSocketTransport', () => {
|
||||||
|
it('throws a typed conn error when WebSocket is unavailable', () => {
|
||||||
|
vi.stubGlobal('WebSocket', undefined);
|
||||||
|
|
||||||
|
const transport = createWebSocketTransport({ url: 'ws://example.test/socket' });
|
||||||
|
|
||||||
|
expect(() => transport.open()).toThrow(ConnWebSocketUnavailableError);
|
||||||
|
try {
|
||||||
|
transport.open();
|
||||||
|
} catch (error) {
|
||||||
|
expect(isConnWebSocketUnavailableError(error)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { ConnectionCloseEvent, ConnectionTransport } from './types.ts';
|
||||||
|
|
||||||
|
export interface ConnectionTransportHandlers {
|
||||||
|
onOpen(): void;
|
||||||
|
onMessage(message: string | ArrayBuffer): void;
|
||||||
|
onClose(event: ConnectionCloseEvent): void;
|
||||||
|
onError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachConnectionTransport(
|
||||||
|
transport: ConnectionTransport,
|
||||||
|
handlers: ConnectionTransportHandlers
|
||||||
|
): () => void {
|
||||||
|
const detachers = [
|
||||||
|
transport.onOpen(handlers.onOpen),
|
||||||
|
transport.onMessage(handlers.onMessage),
|
||||||
|
transport.onClose(handlers.onClose),
|
||||||
|
transport.onError(handlers.onError)
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const detach of detachers) detach();
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import type { FormatsLocaleSource } from './types';
|
||||||
|
|
||||||
|
export interface ActiveFormatsLocaleSourceOptions {
|
||||||
|
locale?: string;
|
||||||
|
localeSource?: FormatsLocaleSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveFormatsLocaleSource {
|
||||||
|
readonly source: FormatsLocaleSource;
|
||||||
|
getLocale: () => string;
|
||||||
|
setLocale: (locale: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveFormatsRuntimeOptions {
|
||||||
|
localeSource?: FormatsLocaleSource;
|
||||||
|
getLocale: () => string;
|
||||||
|
setLocale: (locale: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveFormatsRuntime {
|
||||||
|
read: () => void;
|
||||||
|
notifyChange: () => void;
|
||||||
|
syncLocale: (locale: string) => void;
|
||||||
|
onChange: (fn: () => void) => () => void;
|
||||||
|
onLocaleChange: (fn: (locale: string) => void) => () => void;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActiveFormatsLocaleSource(
|
||||||
|
options: ActiveFormatsLocaleSourceOptions
|
||||||
|
): ActiveFormatsLocaleSource {
|
||||||
|
let currentLocale = options.localeSource?.getLocale() ?? options.locale;
|
||||||
|
const source: FormatsLocaleSource = {
|
||||||
|
getLocale: () => currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? '',
|
||||||
|
onLocaleChange: (fn) => {
|
||||||
|
const unsubscribe = options.localeSource?.onLocaleChange?.((locale) => {
|
||||||
|
currentLocale = locale;
|
||||||
|
fn(locale);
|
||||||
|
});
|
||||||
|
return () => unsubscribe?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
getLocale: source.getLocale,
|
||||||
|
setLocale(locale) {
|
||||||
|
currentLocale = locale;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActiveFormatsRuntime(
|
||||||
|
options: ActiveFormatsRuntimeOptions
|
||||||
|
): ActiveFormatsRuntime {
|
||||||
|
let version = $state(0);
|
||||||
|
const changeListeners = new Set<() => void>();
|
||||||
|
const localeListeners = new Set<(locale: string) => void>();
|
||||||
|
|
||||||
|
function read(): void {
|
||||||
|
void version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChange(): void {
|
||||||
|
version++;
|
||||||
|
changeListeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLocale(locale: string): void {
|
||||||
|
options.setLocale(locale);
|
||||||
|
notifyChange();
|
||||||
|
localeListeners.forEach((fn) => fn(options.getLocale()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeLocale = options.localeSource?.onLocaleChange?.(syncLocale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
read,
|
||||||
|
notifyChange,
|
||||||
|
syncLocale,
|
||||||
|
|
||||||
|
onChange(fn) {
|
||||||
|
changeListeners.add(fn);
|
||||||
|
return () => changeListeners.delete(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
onLocaleChange(fn) {
|
||||||
|
localeListeners.add(fn);
|
||||||
|
return () => localeListeners.delete(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
unsubscribeLocale?.();
|
||||||
|
changeListeners.clear();
|
||||||
|
localeListeners.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
export interface AutoStateOptions<TMode, TValue> {
|
||||||
|
initial?: TMode;
|
||||||
|
auto: TMode;
|
||||||
|
normalize?: (value: TMode) => TMode;
|
||||||
|
toManual?: (value: TMode) => TValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoState<TMode, TValue> {
|
||||||
|
get: (resolveAuto: () => TValue) => TValue;
|
||||||
|
set: (value: TMode) => void;
|
||||||
|
clear: () => void;
|
||||||
|
isAuto: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutoState<TMode, TValue>(
|
||||||
|
options: AutoStateOptions<TMode, TValue>
|
||||||
|
): AutoState<TMode, TValue> {
|
||||||
|
let manual = resolveManual(options.initial);
|
||||||
|
|
||||||
|
function resolveManual(value: TMode | undefined): TValue | null {
|
||||||
|
if (value === undefined || Object.is(value, options.auto)) return null;
|
||||||
|
|
||||||
|
const normalized = options.normalize?.(value) ?? value;
|
||||||
|
if (Object.is(normalized, options.auto)) return null;
|
||||||
|
|
||||||
|
return options.toManual?.(normalized) ?? (normalized as TValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(resolveAuto) {
|
||||||
|
return manual ?? resolveAuto();
|
||||||
|
},
|
||||||
|
|
||||||
|
set(value) {
|
||||||
|
manual = resolveManual(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
manual = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
isAuto() {
|
||||||
|
return manual === null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
createCatalogDiagnostics,
|
||||||
|
type DiagnosticCatalog,
|
||||||
|
type DiagnosticEvent,
|
||||||
|
type Diagnostics,
|
||||||
|
type Logger
|
||||||
|
} from '$libs/logr';
|
||||||
|
import { CURRENCY_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts';
|
||||||
|
import { CURRENCY_ERRORS } from './errors';
|
||||||
|
import type { CurrencyCode } from './types';
|
||||||
|
|
||||||
|
export type CurrencyDiagnosticType =
|
||||||
|
(typeof CURRENCY_DIAGNOSTIC_EVENTS)[keyof typeof CURRENCY_DIAGNOSTIC_EVENTS];
|
||||||
|
|
||||||
|
export interface CurrencyDiagnosticMeta {
|
||||||
|
readonly from?: CurrencyCode;
|
||||||
|
readonly to?: CurrencyCode;
|
||||||
|
readonly rate?: number;
|
||||||
|
readonly error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrencyDiagnosticEvent = DiagnosticEvent<
|
||||||
|
CurrencyDiagnosticType,
|
||||||
|
CurrencyDiagnosticMeta
|
||||||
|
>;
|
||||||
|
export type CurrencyDiagnostics = Diagnostics<CurrencyDiagnosticEvent>;
|
||||||
|
|
||||||
|
const CURRENCY_DIAGNOSTIC_LOGS: DiagnosticCatalog<CurrencyDiagnosticEvent> = {
|
||||||
|
[CURRENCY_DIAGNOSTIC_EVENTS.RATES_PROVIDER_MISSING]: {
|
||||||
|
level: LogLevel.DEBUG,
|
||||||
|
message: CURRENCY_ERRORS.RATES_PROVIDER_MISSING
|
||||||
|
},
|
||||||
|
[CURRENCY_DIAGNOSTIC_EVENTS.RATE_NOT_AVAILABLE]: (event) => ({
|
||||||
|
level: LogLevel.DEBUG,
|
||||||
|
message: CURRENCY_ERRORS.RATE_NOT_AVAILABLE(event.meta?.from ?? '', event.meta?.to ?? '')
|
||||||
|
}),
|
||||||
|
[CURRENCY_DIAGNOSTIC_EVENTS.INVALID_RATE]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: CURRENCY_ERRORS.INVALID_RATE(
|
||||||
|
event.meta?.from ?? '',
|
||||||
|
event.meta?.to ?? '',
|
||||||
|
event.meta?.rate ?? Number.NaN
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[CURRENCY_DIAGNOSTIC_EVENTS.RATE_FETCH_FAILED]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: CURRENCY_ERRORS.RATE_FETCH_FAILED(event.meta?.from ?? '', event.meta?.to ?? '')
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCurrencyDiagnostics(logger?: Logger): CurrencyDiagnostics {
|
||||||
|
return createCatalogDiagnostics({
|
||||||
|
logger,
|
||||||
|
defaultCategory: LOGGER_CATEGORY,
|
||||||
|
catalog: CURRENCY_DIAGNOSTIC_LOGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitCurrencyDiagnostic(
|
||||||
|
diagnostics: CurrencyDiagnostics,
|
||||||
|
type: CurrencyDiagnosticType,
|
||||||
|
meta: CurrencyDiagnosticMeta
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
type,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import type { NumbersFormatOptions } from './types';
|
||||||
|
|
||||||
|
const formatCache = new Map<string, Intl.NumberFormat>();
|
||||||
|
|
||||||
|
export function normalizeNumberFormatOptions(
|
||||||
|
options: NumbersFormatOptions = {}
|
||||||
|
): Intl.NumberFormatOptions {
|
||||||
|
const { minDecimals, maxDecimals, ...rest } = options;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
minimumFractionDigits: minDecimals ?? rest.minimumFractionDigits,
|
||||||
|
maximumFractionDigits: maxDecimals ?? rest.maximumFractionDigits
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedNumberFormat(
|
||||||
|
locale: string,
|
||||||
|
options?: Intl.NumberFormatOptions
|
||||||
|
): Intl.NumberFormat {
|
||||||
|
const key = JSON.stringify([locale, options ?? {}]);
|
||||||
|
const cached = formatCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const formatter = new Intl.NumberFormat(locale || undefined, options);
|
||||||
|
formatCache.set(key, formatter);
|
||||||
|
return formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNumberFormatPart(
|
||||||
|
locale: string,
|
||||||
|
value: number,
|
||||||
|
type: Intl.NumberFormatPartTypes
|
||||||
|
): string {
|
||||||
|
const parts = getCachedNumberFormat(locale).formatToParts(value);
|
||||||
|
return parts.find((part) => part.type === type)?.value ?? '';
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { getCachedNumberFormat } from './number-format';
|
||||||
|
|
||||||
|
const numeralMapCache = new Map<string, Map<string, string>>();
|
||||||
|
|
||||||
|
export interface ParseLocaleNumberOptions {
|
||||||
|
locale: string;
|
||||||
|
decimalSeparator: string;
|
||||||
|
groupSeparator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNumeralMap(locale: string): Map<string, string> {
|
||||||
|
const cached = numeralMapCache.get(locale);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const numerals = getCachedNumberFormat(locale, { useGrouping: false }).format(9876543210);
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const [index, char] of [...numerals].entries()) {
|
||||||
|
map.set(char, String(9 - index));
|
||||||
|
}
|
||||||
|
numeralMapCache.set(locale, map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLocaleNumber(
|
||||||
|
value: string,
|
||||||
|
options: ParseLocaleNumberOptions
|
||||||
|
): number | undefined {
|
||||||
|
if (value.trim() === '') return undefined;
|
||||||
|
|
||||||
|
const numerals = buildNumeralMap(options.locale);
|
||||||
|
let normalized = '';
|
||||||
|
for (const char of value.trim()) {
|
||||||
|
normalized += numerals.get(char) ?? char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.groupSeparator !== '') normalized = normalized.replaceAll(options.groupSeparator, '');
|
||||||
|
if (options.decimalSeparator !== '.') {
|
||||||
|
normalized = normalized.replace(options.decimalSeparator, '.');
|
||||||
|
}
|
||||||
|
normalized = normalized.replace(/[\s\u00a0\u200e\u200f]/g, '');
|
||||||
|
normalized = normalized.replace(/[^\d.+\-eE]/g, '');
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createActiveNumbers } from '../active-numbers.svelte';
|
||||||
|
|
||||||
|
describe('createActiveNumbers()', () => {
|
||||||
|
it('syncs locale source changes and notifies active listeners', () => {
|
||||||
|
let locale = 'en-US';
|
||||||
|
let listener: ((locale: string) => void) | undefined;
|
||||||
|
const source = {
|
||||||
|
getLocale: () => locale,
|
||||||
|
onLocaleChange(fn: (nextLocale: string) => void) {
|
||||||
|
listener = fn;
|
||||||
|
return () => {
|
||||||
|
listener = undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const nums = createActiveNumbers({ localeSource: source });
|
||||||
|
const localeChanges: string[] = [];
|
||||||
|
let preferenceChanges = 0;
|
||||||
|
|
||||||
|
nums.onLocaleChange((nextLocale) => localeChanges.push(nextLocale));
|
||||||
|
nums.onPreferenceChange(() => {
|
||||||
|
preferenceChanges++;
|
||||||
|
});
|
||||||
|
|
||||||
|
locale = 'de-DE';
|
||||||
|
listener?.(locale);
|
||||||
|
|
||||||
|
expect(nums.getLocale()).toBe('de-DE');
|
||||||
|
expect(nums.getDecimalSeparator()).toBe(',');
|
||||||
|
expect(localeChanges).toEqual(['de-DE']);
|
||||||
|
expect(preferenceChanges).toBe(1);
|
||||||
|
|
||||||
|
nums.setGrouping(false);
|
||||||
|
expect(nums.getGrouping()).toBe(false);
|
||||||
|
expect(preferenceChanges).toBe(2);
|
||||||
|
|
||||||
|
nums.dispose();
|
||||||
|
expect(listener).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createAutoState } from '../auto-state';
|
||||||
|
|
||||||
|
describe('createAutoState()', () => {
|
||||||
|
it('switches between resolved auto values and manual values', () => {
|
||||||
|
const state = createAutoState<'auto' | 'manual', string>({
|
||||||
|
initial: 'auto',
|
||||||
|
auto: 'auto',
|
||||||
|
toManual: (value) => value
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.isAuto()).toBe(true);
|
||||||
|
expect(state.get(() => 'resolved')).toBe('resolved');
|
||||||
|
|
||||||
|
state.set('manual');
|
||||||
|
|
||||||
|
expect(state.isAuto()).toBe(false);
|
||||||
|
expect(state.get(() => 'resolved')).toBe('manual');
|
||||||
|
|
||||||
|
state.clear();
|
||||||
|
|
||||||
|
expect(state.isAuto()).toBe(true);
|
||||||
|
expect(state.get(() => 'resolved')).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes manual values before storing them', () => {
|
||||||
|
const state = createAutoState<string, string>({
|
||||||
|
initial: 'eur',
|
||||||
|
auto: 'auto',
|
||||||
|
normalize: (value) => value.toLowerCase(),
|
||||||
|
toManual: (value) => value.toUpperCase()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.get(() => 'USD')).toBe('EUR');
|
||||||
|
|
||||||
|
state.set('AUTO');
|
||||||
|
expect(state.isAuto()).toBe(true);
|
||||||
|
expect(state.get(() => 'USD')).toBe('USD');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
createCatalogDiagnostics,
|
||||||
|
type DiagnosticCatalog,
|
||||||
|
type DiagnosticEvent,
|
||||||
|
type Diagnostics,
|
||||||
|
type Logger
|
||||||
|
} from '$libs/logr';
|
||||||
|
import {
|
||||||
|
HTTP_DIAGNOSTIC_EVENTS,
|
||||||
|
HTTP_METHOD_GET,
|
||||||
|
LOGGER_CATEGORY,
|
||||||
|
bodySchemaRejectedLogMessage,
|
||||||
|
httpStatusLogMessage,
|
||||||
|
networkErrorLogMessage,
|
||||||
|
requestLogMessage,
|
||||||
|
responseSchemaFailedLogMessage,
|
||||||
|
retryingLogMessage
|
||||||
|
} from './consts.ts';
|
||||||
|
import type { HttpMethod } from './types.ts';
|
||||||
|
|
||||||
|
export type HttpDiagnosticType =
|
||||||
|
(typeof HTTP_DIAGNOSTIC_EVENTS)[keyof typeof HTTP_DIAGNOSTIC_EVENTS];
|
||||||
|
|
||||||
|
export interface HttpDiagnosticMeta {
|
||||||
|
readonly method: HttpMethod;
|
||||||
|
readonly url: string;
|
||||||
|
readonly attempt?: number;
|
||||||
|
readonly error?: unknown;
|
||||||
|
readonly issueCount?: number;
|
||||||
|
readonly retryDelay?: number;
|
||||||
|
readonly nextAttempt?: number;
|
||||||
|
readonly totalAttempts?: number;
|
||||||
|
readonly status?: number;
|
||||||
|
readonly statusText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpDiagnosticEvent = DiagnosticEvent<HttpDiagnosticType, HttpDiagnosticMeta>;
|
||||||
|
export type HttpDiagnostics = Diagnostics<HttpDiagnosticEvent>;
|
||||||
|
|
||||||
|
const HTTP_DIAGNOSTIC_LOGS: DiagnosticCatalog<HttpDiagnosticEvent> = {
|
||||||
|
[HTTP_DIAGNOSTIC_EVENTS.REQUEST]: (event) => ({
|
||||||
|
level: LogLevel.DEBUG,
|
||||||
|
message: requestLogMessage(methodOf(event), urlOf(event))
|
||||||
|
}),
|
||||||
|
[HTTP_DIAGNOSTIC_EVENTS.BODY_SCHEMA_REJECTED]: (event) => ({
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: bodySchemaRejectedLogMessage(methodOf(event), urlOf(event))
|
||||||
|
}),
|
||||||
|
[HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: networkErrorLogMessage(methodOf(event), urlOf(event))
|
||||||
|
}),
|
||||||
|
[HTTP_DIAGNOSTIC_EVENTS.RETRYING]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: retryingLogMessage(
|
||||||
|
methodOf(event),
|
||||||
|
urlOf(event),
|
||||||
|
event.meta?.retryDelay ?? 0,
|
||||||
|
event.meta?.nextAttempt ?? 0,
|
||||||
|
event.meta?.totalAttempts ?? 0
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[HTTP_DIAGNOSTIC_EVENTS.RESPONSE_SCHEMA_FAILED]: (event) => ({
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: responseSchemaFailedLogMessage(methodOf(event), urlOf(event))
|
||||||
|
}),
|
||||||
|
[HTTP_DIAGNOSTIC_EVENTS.HTTP_STATUS]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: httpStatusLogMessage(
|
||||||
|
methodOf(event),
|
||||||
|
urlOf(event),
|
||||||
|
event.meta?.status ?? 0,
|
||||||
|
event.meta?.statusText ?? ''
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
function methodOf(event: HttpDiagnosticEvent): HttpMethod {
|
||||||
|
return event.meta?.method ?? HTTP_METHOD_GET;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlOf(event: HttpDiagnosticEvent): string {
|
||||||
|
return event.meta?.url ?? LOGGER_CATEGORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpDiagnostics(logger?: Logger): HttpDiagnostics {
|
||||||
|
return createCatalogDiagnostics({
|
||||||
|
logger,
|
||||||
|
defaultCategory: LOGGER_CATEGORY,
|
||||||
|
catalog: HTTP_DIAGNOSTIC_LOGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitHttpDiagnostic(
|
||||||
|
diagnostics: HttpDiagnostics,
|
||||||
|
type: HttpDiagnosticType,
|
||||||
|
meta: HttpDiagnosticMeta
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
type,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import { applyHeaders } from './body.ts';
|
||||||
|
import { DEFAULT_RETRY, DEFAULT_TIMEOUT } from './consts.ts';
|
||||||
|
import { createHttpDiagnostics, type HttpDiagnostics } from './diagnostics.ts';
|
||||||
|
import type { EngineHttpOptions, HttpHooks, HttpInit, RetryConfig } from './types.ts';
|
||||||
|
|
||||||
|
export interface ResolvedHttpDefaults {
|
||||||
|
readonly baseUrl: string | undefined;
|
||||||
|
readonly headers: EngineHttpOptions['headers'];
|
||||||
|
readonly fetch: typeof fetch;
|
||||||
|
readonly timeout: number;
|
||||||
|
readonly totalTimeout: number;
|
||||||
|
readonly retry: RetryConfig;
|
||||||
|
readonly hooks: Required<HttpHooks>;
|
||||||
|
readonly diagnostics: HttpDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freezeHttpDefaults(options: EngineHttpOptions): ResolvedHttpDefaults {
|
||||||
|
const retry = { ...DEFAULT_RETRY, ...options.retry };
|
||||||
|
return {
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
headers: options.headers,
|
||||||
|
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
||||||
|
timeout: options.timeout ?? DEFAULT_TIMEOUT,
|
||||||
|
totalTimeout: options.totalTimeout ?? 0,
|
||||||
|
retry,
|
||||||
|
hooks: {
|
||||||
|
beforeRequest: options.hooks?.beforeRequest ?? [],
|
||||||
|
beforeRetry: options.hooks?.beforeRetry ?? [],
|
||||||
|
afterResponse: options.hooks?.afterResponse ?? [],
|
||||||
|
beforeError: options.hooks?.beforeError ?? []
|
||||||
|
},
|
||||||
|
diagnostics: createHttpDiagnostics(options.logger)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeHttpOptions(
|
||||||
|
parent: EngineHttpOptions,
|
||||||
|
override: EngineHttpOptions
|
||||||
|
): EngineHttpOptions {
|
||||||
|
return {
|
||||||
|
baseUrl: override.baseUrl ?? parent.baseUrl,
|
||||||
|
// Headers compose: when both layers set them, build a synthetic hook
|
||||||
|
// that applies parent first, override second (override wins on
|
||||||
|
// per-key conflict because `applyHeaders` uses `Headers.set`).
|
||||||
|
headers: composeHeaderSources(parent.headers, override.headers),
|
||||||
|
fetch: override.fetch ?? parent.fetch,
|
||||||
|
timeout: override.timeout ?? parent.timeout,
|
||||||
|
totalTimeout: override.totalTimeout ?? parent.totalTimeout,
|
||||||
|
retry: { ...parent.retry, ...override.retry },
|
||||||
|
hooks: {
|
||||||
|
beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest),
|
||||||
|
beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry),
|
||||||
|
afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse),
|
||||||
|
beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError)
|
||||||
|
},
|
||||||
|
logger: override.logger ?? parent.logger
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHttpCallRetry(
|
||||||
|
defaults: ResolvedHttpDefaults,
|
||||||
|
init: HttpInit | undefined
|
||||||
|
): RetryConfig | null {
|
||||||
|
if (init?.retry === false) return null;
|
||||||
|
if (init?.retry === undefined) return defaults.retry;
|
||||||
|
return { ...defaults.retry, ...init.retry };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHttpCallHooks(
|
||||||
|
defaults: ResolvedHttpDefaults,
|
||||||
|
init: HttpInit | undefined
|
||||||
|
): Required<HttpHooks> {
|
||||||
|
if (init?.hooks === undefined) return defaults.hooks;
|
||||||
|
return {
|
||||||
|
beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])],
|
||||||
|
beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])],
|
||||||
|
afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])],
|
||||||
|
beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeHeaderSources(
|
||||||
|
parent: EngineHttpOptions['headers'],
|
||||||
|
override: EngineHttpOptions['headers']
|
||||||
|
): EngineHttpOptions['headers'] {
|
||||||
|
if (parent === undefined) return override;
|
||||||
|
if (override === undefined) return parent;
|
||||||
|
return async () => {
|
||||||
|
const target = new Headers();
|
||||||
|
await applyHeaders(target, parent);
|
||||||
|
await applyHeaders(target, override);
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatHooks<H>(
|
||||||
|
a: ReadonlyArray<H> | undefined,
|
||||||
|
b: ReadonlyArray<H> | undefined
|
||||||
|
): ReadonlyArray<H> | undefined {
|
||||||
|
if (a === undefined) return b;
|
||||||
|
if (b === undefined) return a;
|
||||||
|
return [...a, ...b];
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
AfterResponseHook,
|
||||||
|
BeforeErrorHook,
|
||||||
|
BeforeRequestHook,
|
||||||
|
BeforeRetryHook,
|
||||||
|
HookContext
|
||||||
|
} from './types.ts';
|
||||||
|
|
||||||
|
export async function runBeforeRequest(
|
||||||
|
hooks: ReadonlyArray<BeforeRequestHook>,
|
||||||
|
ctx: HookContext
|
||||||
|
): Promise<Response | undefined> {
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const result = await hook(ctx);
|
||||||
|
if (result instanceof Response) return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBeforeRetry(
|
||||||
|
hooks: ReadonlyArray<BeforeRetryHook>,
|
||||||
|
ctx: HookContext & { error: unknown; retryDelay: number }
|
||||||
|
): Promise<void> {
|
||||||
|
for (const hook of hooks) {
|
||||||
|
await hook(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAfterResponse(
|
||||||
|
hooks: ReadonlyArray<AfterResponseHook>,
|
||||||
|
ctx: HookContext,
|
||||||
|
response: Response
|
||||||
|
): Promise<Response> {
|
||||||
|
let current = response;
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const result = await hook({ ...ctx, response: current });
|
||||||
|
if (result instanceof Response) current = result;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBeforeError(
|
||||||
|
hooks: ReadonlyArray<BeforeErrorHook>,
|
||||||
|
ctx: HookContext & { response?: Response },
|
||||||
|
error: unknown
|
||||||
|
): Promise<Response | undefined> {
|
||||||
|
let current: Response | undefined = ctx.response;
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const result = await hook({ ...ctx, response: current, error });
|
||||||
|
if (result instanceof Response) current = result;
|
||||||
|
}
|
||||||
|
// Only return when the hook actually swapped in a fresh response.
|
||||||
|
if (current !== undefined && current !== ctx.response) return current;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import type { StandardSchemaV1 } from '$libs/standard-schema';
|
||||||
|
import { isPromiseLike } from '$libs/standard-schema';
|
||||||
|
import { parseBody } from './body.ts';
|
||||||
|
import {
|
||||||
|
HTTP_RESULT_KIND_HTTP,
|
||||||
|
HTTP_RESULT_KIND_VALIDATION
|
||||||
|
} from './consts.ts';
|
||||||
|
import type { HttpMethod, HttpResult, Out } from './types.ts';
|
||||||
|
|
||||||
|
export async function buildOkOrValidation<S extends StandardSchemaV1 | undefined>(
|
||||||
|
response: Response,
|
||||||
|
method: HttpMethod,
|
||||||
|
schema: S | undefined
|
||||||
|
): Promise<HttpResult<Out<S>>> {
|
||||||
|
if (schema === undefined) {
|
||||||
|
return { ok: true, value: undefined as Out<S>, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await parseBody(response, method);
|
||||||
|
const validated = await runStandardValidate(schema, body);
|
||||||
|
if (validated.issues !== undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
kind: HTTP_RESULT_KIND_VALIDATION,
|
||||||
|
issues: validated.issues,
|
||||||
|
response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, value: validated.value as Out<S>, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildHttpFailure<S extends StandardSchemaV1 | undefined>(
|
||||||
|
response: Response,
|
||||||
|
method: HttpMethod
|
||||||
|
): Promise<HttpResult<Out<S>>> {
|
||||||
|
const body = await parseBody(response, method);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
kind: HTTP_RESULT_KIND_HTTP,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body,
|
||||||
|
response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStandardValidate<O>(
|
||||||
|
schema: StandardSchemaV1<unknown, O>,
|
||||||
|
value: unknown
|
||||||
|
): StandardSchemaV1.Result<O> | Promise<StandardSchemaV1.Result<O>> {
|
||||||
|
const result = schema['~standard'].validate(value);
|
||||||
|
return isPromiseLike(result) ? result : result;
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
LogLevel,
|
||||||
|
createCatalogDiagnostics,
|
||||||
|
type DiagnosticCatalog,
|
||||||
|
type DiagnosticEvent,
|
||||||
|
type Diagnostics,
|
||||||
|
type Logger
|
||||||
|
} from '$libs/logr';
|
||||||
|
import { LANG_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts.ts';
|
||||||
|
import { LANG_ERRORS } from './errors.ts';
|
||||||
|
import type { SupportedLocale } from './types.ts';
|
||||||
|
|
||||||
|
export type LangDiagnosticType =
|
||||||
|
(typeof LANG_DIAGNOSTIC_EVENTS)[keyof typeof LANG_DIAGNOSTIC_EVENTS];
|
||||||
|
|
||||||
|
export interface LangDiagnosticMeta {
|
||||||
|
readonly path?: string;
|
||||||
|
readonly locale?: SupportedLocale;
|
||||||
|
readonly usedLocale?: SupportedLocale;
|
||||||
|
readonly siblingCount?: number;
|
||||||
|
readonly fallback?: string;
|
||||||
|
readonly kind?: 't' | 'ts';
|
||||||
|
readonly error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LangDiagnosticEvent = DiagnosticEvent<LangDiagnosticType, LangDiagnosticMeta>;
|
||||||
|
export type LangDiagnostics = Diagnostics<LangDiagnosticEvent>;
|
||||||
|
|
||||||
|
const LANG_DIAGNOSTIC_LOGS: DiagnosticCatalog<LangDiagnosticEvent> = {
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.KEY_NOT_FOUND]: (event) => ({
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LANG_ERRORS.KEY_NOT_FOUND(event.meta?.path ?? '')
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.CIRCULAR_REFERENCE]: (event) => ({
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: LANG_ERRORS.CIRCULAR_REFERENCE(event.meta?.path ?? '')
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.MISSING_TRANSLATION(
|
||||||
|
event.meta?.path ?? '',
|
||||||
|
event.meta?.locale ?? 'es',
|
||||||
|
event.meta?.usedLocale ?? 'es'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.MISSING_TRANSLATION_RECORD]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.MISSING_TRANSLATION_RECORD(
|
||||||
|
event.meta?.locale ?? 'es',
|
||||||
|
event.meta?.usedLocale ?? 'es'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.AMBIGUOUS_SIBLING_MATCH]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.AMBIGUOUS_SIBLING_MATCH(
|
||||||
|
event.meta?.locale ?? 'es',
|
||||||
|
event.meta?.usedLocale ?? 'es',
|
||||||
|
event.meta?.siblingCount ?? 0,
|
||||||
|
event.meta?.path
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.EXTEND_LEAF_OVERWRITE]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.EXTEND_LEAF_OVERWRITE(event.meta?.path ?? '')
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.LOGGER_ALREADY_SET]: {
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.LOGGER_ALREADY_SET
|
||||||
|
},
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.REFERENCE_FALLBACK_USED]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.REFERENCE_FALLBACK_USED(
|
||||||
|
event.meta?.path ?? '',
|
||||||
|
event.meta?.fallback ?? ''
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[LANG_DIAGNOSTIC_EVENTS.MONO_PATH_RETURNED]: (event) => ({
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message: LANG_ERRORS.MONO_PATH_RETURNED(
|
||||||
|
event.meta?.path ?? '',
|
||||||
|
event.meta?.kind ?? 't'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createLangDiagnostics(logger?: Logger): LangDiagnostics {
|
||||||
|
return createCatalogDiagnostics({
|
||||||
|
logger,
|
||||||
|
defaultCategory: LOGGER_CATEGORY,
|
||||||
|
catalog: LANG_DIAGNOSTIC_LOGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitLangDiagnostic(
|
||||||
|
diagnostics: LangDiagnostics,
|
||||||
|
type: LangDiagnosticType,
|
||||||
|
meta: LangDiagnosticMeta,
|
||||||
|
scope?: string
|
||||||
|
): void {
|
||||||
|
diagnostics.emit({
|
||||||
|
artifact: LOGGER_CATEGORY,
|
||||||
|
type,
|
||||||
|
scope,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue