auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4
@@ -0,0 +1,410 @@
|
||||
# 0017 — Frontend Data Hooks
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0017 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
Ninguna.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear hooks React genericos para comunicacion con APIs HTTP (REST, SSE, WebSocket) que sirvan como base para cualquier frontend web. Actualmente solo existen hooks Wails (`use_wails_query`, `use_wails_mutation`, `use_wails_event`, `use_wails_stream`) que dependen de IPC Wails — no hay nada para apps web estandar que usan `fetch` contra un servidor HTTP.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Los hooks Wails (`frontend/functions/ui/`) implementan un patron solido: cache, stale-while-revalidate, retry, optimistic updates. Pero estan atados a `window.runtime` de Wails.
|
||||
- El registry tiene 70+ componentes Mantine en `frontend/functions/ui/` pero cero infraestructura de data-fetching para web.
|
||||
- Cualquier app web nueva (dashboards, admin panels, SPAs) necesita reinventar el fetching desde cero o tirar de react-query/swr como dependencia externa.
|
||||
- Con estos hooks, una app web solo importa `use_fetch`, `use_mutation`, etc. del registry y tiene todo resuelto. Misma filosofia que los hooks Wails pero sobre HTTP.
|
||||
- El issue 0009 (HTTP Server) crea las primitivas del servidor. Este issue crea las primitivas del cliente que las consume.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
frontend/functions/core/
|
||||
├── api_client.ts — NEW: fetch wrapper configurable
|
||||
├── api_client.md — NEW
|
||||
├── use_fetch.tsx — NEW: hook GET con cache + SWR
|
||||
├── use_fetch.md — NEW
|
||||
├── use_mutation.tsx — NEW: hook POST/PUT/DELETE
|
||||
├── use_mutation.md — NEW
|
||||
├── use_infinite_scroll.tsx — NEW: hook paginacion infinita
|
||||
├── use_infinite_scroll.md — NEW
|
||||
├── use_form.tsx — NEW: hook formularios
|
||||
├── use_form.md — NEW
|
||||
├── use_debounced_search.tsx — NEW: hook busqueda con debounce
|
||||
├── use_debounced_search.md — NEW
|
||||
├── use_sse.tsx — NEW: hook Server-Sent Events
|
||||
├── use_sse.md — NEW
|
||||
├── use_websocket.tsx — NEW: hook WebSocket
|
||||
├── use_websocket.md — NEW
|
||||
|
||||
frontend/types/core/
|
||||
├── fetch_state.ts — NEW: tipo FetchState
|
||||
├── fetch_state.md — NEW
|
||||
├── mutation_state.ts — NEW: tipo MutationState
|
||||
├── mutation_state.md — NEW
|
||||
├── form_state.ts — NEW: tipo FormState
|
||||
├── form_state.md — NEW
|
||||
├── api_client_config.ts — NEW: tipo APIClientConfig
|
||||
├── api_client_config.md — NEW
|
||||
```
|
||||
|
||||
Todas las funciones van en `frontend/functions/core/` (dominio `core`) porque son TypeScript puro, framework-agnostic en el sentido de que no dependen de Wails, Electron, Tauri, ni ningun runtime especifico. Solo usan React + `fetch` nativo del browser.
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
No hay funciones puras en este issue — todos los hooks manejan estado (React) y/o I/O (HTTP, SSE, WebSocket). `api_client` es una funcion impura (hace `fetch`), no un hook.
|
||||
|
||||
## Diseno
|
||||
|
||||
### Tipos
|
||||
|
||||
```typescript
|
||||
/** Estado de un fetch GET */
|
||||
interface FetchState<T> {
|
||||
data: T | null
|
||||
error: Error | null
|
||||
loading: boolean
|
||||
/** Re-ejecutar la peticion */
|
||||
refetch: () => Promise<T>
|
||||
/** Si los datos son stale (cache viejo mostrando mientras refetch) */
|
||||
is_stale: boolean
|
||||
}
|
||||
|
||||
/** Estado de una mutacion POST/PUT/DELETE */
|
||||
interface MutationState<TData, TVariables> {
|
||||
/** Ejecutar la mutacion */
|
||||
mutate: (variables: TVariables) => void
|
||||
/** Ejecutar la mutacion (async, retorna Promise) */
|
||||
mutate_async: (variables: TVariables) => Promise<TData>
|
||||
data: TData | null
|
||||
error: Error | null
|
||||
loading: boolean
|
||||
/** Resetear al estado inicial */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/** Estado de un formulario */
|
||||
interface FormState<T extends Record<string, unknown>> {
|
||||
/** Valores actuales del form */
|
||||
values: T
|
||||
/** Errores por campo */
|
||||
errors: Partial<Record<keyof T, string>>
|
||||
/** Campos que el usuario ha tocado */
|
||||
touched: Partial<Record<keyof T, boolean>>
|
||||
/** Si todos los campos pasan validacion */
|
||||
is_valid: boolean
|
||||
/** Si el submit esta en progreso */
|
||||
is_submitting: boolean
|
||||
}
|
||||
|
||||
/** Configuracion del cliente HTTP */
|
||||
interface APIClientConfig {
|
||||
/** URL base para todas las peticiones (ej: "http://localhost:8484/api") */
|
||||
base_url: string
|
||||
/** Headers fijos para todas las peticiones */
|
||||
headers?: Record<string, string>
|
||||
/** Callback global de error */
|
||||
on_error?: (error: Error, url: string, init?: RequestInit) => void
|
||||
/** Callback cuando el server responde 401 */
|
||||
on_unauthorized?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Funcion | Kind | Purity | Firma (simplificada) |
|
||||
|---------|------|--------|---------------------|
|
||||
| `api_client` | function | impure | `(config: APIClientConfig) => { get, post, put, del, patch }` |
|
||||
| `use_fetch` | component | impure | `useFetch<T>(url, opts?): FetchState<T>` |
|
||||
| `use_mutation` | component | impure | `useMutation<TData, TVars>(opts): MutationState<TData, TVars>` |
|
||||
| `use_infinite_scroll` | component | impure | `useInfiniteScroll<T>(url, opts?): { data, loading, has_more, load_more, reset }` |
|
||||
| `use_form` | component | impure | `useForm<T>(opts): { values, errors, touched, set_field, validate, submit, reset }` |
|
||||
| `use_debounced_search` | component | impure | `useDebouncedSearch<T>(url, opts?): { query, set_query, results, loading }` |
|
||||
| `use_sse` | component | impure | `useSSE<T>(url, opts?): { data, last_event, status, close }` |
|
||||
| `use_websocket` | component | impure | `useWebSocket<TSend, TRecv>(url, opts?): { send, last_message, status, close }` |
|
||||
|
||||
### Detalle de cada funcion
|
||||
|
||||
**`api_client`** — Factory que retorna un objeto con metodos HTTP. No es un hook, es una funcion normal que crea un cliente reutilizable. Cada metodo hace `fetch` con los headers y base_url configurados, parsea JSON automaticamente, y llama a `on_error`/`on_unauthorized` segun el status code.
|
||||
|
||||
```typescript
|
||||
const api = apiClient({ base_url: 'http://localhost:8484/api' })
|
||||
const users = await api.get<User[]>('/users')
|
||||
await api.post('/users', { name: 'Lucas' })
|
||||
await api.del('/users/123')
|
||||
```
|
||||
|
||||
**`use_fetch`** — Hook para GET requests. Hace fetch al montar (configurable con `enabled`). Cache en memoria con stale-while-revalidate: muestra datos viejos inmediatamente y refetchea en background. Soporta `refetch_interval`, `refetch_on_focus`, `stale_time`, `retry`. Recibe una URL string o una funcion que retorna `Promise<T>` (para usar con `api_client`).
|
||||
|
||||
**`use_mutation`** — Hook para operaciones de escritura. No ejecuta al montar — expone `mutate()` y `mutate_async()`. Soporta `on_mutate` para optimistic updates (retorna rollback context), `on_success`, `on_error`, `on_settled`. Opcionalmente invalida queries del cache al completar.
|
||||
|
||||
**`use_infinite_scroll`** — Hook para paginacion infinita. Soporta cursor-based (`next_cursor` en response) y offset-based (`page` incrementando). Expone `load_more()`, `has_more`, `reset()`. Concatena paginas en un solo array plano.
|
||||
|
||||
**`use_form`** — Hook para formularios. Recibe `initial_values` y opcionalmente `validate` (funcion que retorna errores por campo). Expone `set_field(name, value)`, `set_fields(partial)`, `validate()`, `submit(handler)`, `reset()`. El submit llama a `validate()` primero y solo ejecuta el handler si pasa. Marca campos como `touched` al hacer set.
|
||||
|
||||
**`use_debounced_search`** — Hook que combina un input debounced con fetch. Recibe la URL base y el debounce delay (default 300ms). Mientras el usuario escribe, espera N ms sin actividad y luego hace GET con el query como parametro. Expone `query`, `set_query`, `results`, `loading`.
|
||||
|
||||
**`use_sse`** — Hook para Server-Sent Events. Crea un `EventSource` al montar, parsea cada evento como JSON, auto-reconecta con backoff exponencial si se pierde la conexion. Expone `data` (array de eventos), `last_event`, `status` (connecting/open/closed), `close()`.
|
||||
|
||||
**`use_websocket`** — Hook para WebSocket. Conecta al montar, auto-reconecta con backoff. Expone `send(data)`, `last_message`, `messages` (buffer), `status` (connecting/open/closing/closed), `close()`. Soporta tipado separado para envio (`TSend`) y recepcion (`TRecv`).
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos y api_client
|
||||
|
||||
- [ ] **1.1** Crear tipo `FetchState` en `frontend/types/core/fetch_state.ts` + `.md`
|
||||
- [ ] **1.2** Crear tipo `MutationState` en `frontend/types/core/mutation_state.ts` + `.md`
|
||||
- [ ] **1.3** Crear tipo `FormState` en `frontend/types/core/form_state.ts` + `.md`
|
||||
- [ ] **1.4** Crear tipo `APIClientConfig` en `frontend/types/core/api_client_config.ts` + `.md`
|
||||
- [ ] **1.5** Crear `api_client` en `frontend/functions/core/api_client.ts` + `.md` — fetch wrapper con base_url, headers, error handling, interceptors
|
||||
|
||||
### Fase 2: Hooks de datos (core HTTP)
|
||||
|
||||
- [ ] **2.1** `use_fetch` — GET con cache in-memory, stale-while-revalidate, retry, refetch por intervalo/foco
|
||||
- [ ] **2.2** `use_mutation` — POST/PUT/DELETE con optimistic updates, invalidacion de queries
|
||||
- [ ] **2.3** `use_debounced_search` — input con debounce + fetch automatico
|
||||
- [ ] **2.4** `use_form` — state de formulario con validacion, touched, submit handler
|
||||
|
||||
### Fase 3: Hooks de streaming y paginacion
|
||||
|
||||
- [ ] **3.1** `use_infinite_scroll` — paginacion infinita (cursor y offset), concatenacion de paginas
|
||||
- [ ] **3.2** `use_sse` — Server-Sent Events con auto-reconnect y parsing JSON
|
||||
- [ ] **3.3** `use_websocket` — WebSocket con auto-reconnect, send tipado, buffer de mensajes
|
||||
|
||||
### Fase 4: Integracion y verificacion
|
||||
|
||||
- [ ] **4.1** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
|
||||
- [ ] **4.2** Verificar que los IDs siguen el patron `{name}_ts_core`
|
||||
- [ ] **4.3** Verificar imports cruzados: los hooks importan los tipos de `frontend/types/core/`
|
||||
- [ ] **4.4** Verificar que `api_client` se puede pasar como `fetch_fn` a `use_fetch` y `use_mutation`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
Pagina CRUD completa usando los hooks con el layout `crud_page_ts_ui` existente:
|
||||
|
||||
```tsx
|
||||
import { apiClient } from '@fn_library/core/api_client'
|
||||
import { useFetch } from '@fn_library/core/use_fetch'
|
||||
import { useMutation } from '@fn_library/core/use_mutation'
|
||||
import { useForm } from '@fn_library/core/use_form'
|
||||
import { useDebouncedSearch } from '@fn_library/core/use_debounced_search'
|
||||
import { crudPage } from '@fn_library/ui/crud_page'
|
||||
import { Stack, Modal, TextInput, Button, Group } from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
|
||||
// 1. Configurar cliente HTTP una vez
|
||||
const api = apiClient({
|
||||
base_url: 'http://localhost:8484/api',
|
||||
on_unauthorized: () => window.location.href = '/login',
|
||||
})
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
|
||||
// 2. Fetch lista de usuarios con cache
|
||||
const { data: users, loading, refetch } = useFetch<User[]>({
|
||||
url: '/users',
|
||||
fetch_fn: api.get,
|
||||
stale_time: 10_000, // Fresh por 10s
|
||||
refetch_on_focus: true, // Refetch al volver a la tab
|
||||
})
|
||||
|
||||
// 3. Busqueda con debounce
|
||||
const { query, set_query, results: searchResults, loading: searching } = useDebouncedSearch<User[]>({
|
||||
url: '/users/search',
|
||||
fetch_fn: api.get,
|
||||
delay: 300,
|
||||
param_name: 'q',
|
||||
})
|
||||
|
||||
// 4. Mutacion para crear usuario
|
||||
const { mutate: createUser, loading: creating } = useMutation<User, Partial<User>>({
|
||||
mutation_fn: (data) => api.post('/users', data),
|
||||
on_success: () => {
|
||||
refetch()
|
||||
close()
|
||||
form.reset()
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Mutacion para eliminar usuario
|
||||
const { mutate: deleteUser } = useMutation<void, string>({
|
||||
mutation_fn: (id) => api.del(`/users/${id}`),
|
||||
on_success: () => refetch(),
|
||||
})
|
||||
|
||||
// 6. Form con validacion
|
||||
const form = useForm<Partial<User>>({
|
||||
initial_values: { name: '', email: '', role: 'user' },
|
||||
validate: (values) => ({
|
||||
name: !values.name ? 'Nombre requerido' : undefined,
|
||||
email: !values.email?.includes('@') ? 'Email invalido' : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const displayedUsers = query ? searchResults : users
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Barra de busqueda */}
|
||||
<TextInput
|
||||
placeholder="Buscar usuarios..."
|
||||
value={query}
|
||||
onChange={(e) => set_query(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{/* Tabla CRUD usando crud_page del registry */}
|
||||
{crudPage<User>({
|
||||
title: 'Users',
|
||||
subtitle: `${displayedUsers?.length ?? 0} usuarios`,
|
||||
data: displayedUsers ?? [],
|
||||
fields: [
|
||||
{ key: 'name', label: 'Nombre', type: 'text', required: true },
|
||||
{ key: 'email', label: 'Email', type: 'email', required: true },
|
||||
{ key: 'role', label: 'Rol', type: 'select', options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'User', value: 'user' },
|
||||
]},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nombre' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'role', label: 'Rol' },
|
||||
],
|
||||
onAdd: open,
|
||||
onDelete: (user) => deleteUser(user.id),
|
||||
})}
|
||||
|
||||
{/* Modal de creacion */}
|
||||
<Modal opened={opened} onClose={close} title="Nuevo usuario">
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
value={form.values.name ?? ''}
|
||||
error={form.errors.name}
|
||||
onChange={(e) => form.set_field('name', e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={form.values.email ?? ''}
|
||||
error={form.errors.email}
|
||||
onChange={(e) => form.set_field('email', e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={close}>Cancelar</Button>
|
||||
<Button
|
||||
loading={creating}
|
||||
onClick={() => form.submit((values) => createUser(values))}
|
||||
>
|
||||
Crear
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Ejemplo con SSE (logs en tiempo real)
|
||||
|
||||
```tsx
|
||||
import { useSSE } from '@fn_library/core/use_sse'
|
||||
import { Paper, Code, ScrollArea, Badge, Group } from '@mantine/core'
|
||||
|
||||
function LogViewer() {
|
||||
const { data: logs, last_event, status, close } = useSSE<string>({
|
||||
url: 'http://localhost:8484/api/logs/stream',
|
||||
reconnect: true,
|
||||
reconnect_interval: 3000,
|
||||
})
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md">
|
||||
<Group mb="sm">
|
||||
<Badge color={status === 'open' ? 'green' : 'red'}>{status}</Badge>
|
||||
</Group>
|
||||
<ScrollArea h={400}>
|
||||
<Code block>{logs.join('\n')}</Code>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Ejemplo con WebSocket (chat)
|
||||
|
||||
```tsx
|
||||
import { useWebSocket } from '@fn_library/core/use_websocket'
|
||||
import { TextInput, Paper, Stack, Text } from '@mantine/core'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface ChatMessage { user: string; text: string; ts: number }
|
||||
|
||||
function Chat() {
|
||||
const [input, setInput] = useState('')
|
||||
const { send, messages, status } = useWebSocket<{ text: string }, ChatMessage>({
|
||||
url: 'ws://localhost:8484/ws/chat',
|
||||
reconnect: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Paper withBorder p="md" h={300} style={{ overflow: 'auto' }}>
|
||||
{messages.map((msg, i) => (
|
||||
<Text key={i} size="sm"><b>{msg.user}:</b> {msg.text}</Text>
|
||||
))}
|
||||
</Paper>
|
||||
<TextInput
|
||||
placeholder="Escribir mensaje..."
|
||||
value={input}
|
||||
disabled={status !== 'open'}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && input.trim()) {
|
||||
send({ text: input })
|
||||
setInput('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Zero dependencias externas:** No se usa react-query, swr, axios, ni ninguna libreria de fetching. Los hooks implementan un patron SWR-like minimalista con `fetch` nativo y `useState`/`useEffect`/`useRef`/`useCallback` de React. Esto mantiene el bundle pequeno y elimina dependencias que evolucionan independientemente.
|
||||
- **Cache in-memory simple (Map):** Los hooks Wails ya tienen un cache propio (`wails_cache.ts`). Los nuevos hooks usan un `Map<string, { data, timestamp }>` simple compartido via un modulo singleton. No se necesita un cache manager complejo — el patron stale-while-revalidate con un Map cubre el 90% de los casos.
|
||||
- **`api_client` es funcion, no hook:** El cliente HTTP es una factory pura (en el sentido de React) que retorna metodos. Se puede usar fuera de componentes: en event handlers, en funciones de utilidad, en tests. Los hooks lo consumen opcionalmente via `fetch_fn`.
|
||||
- **Dominio `core`, no `ui`:** Los hooks Wails viven en `ui` porque dependen de `wails_provider` y el runtime Wails (infraestructura de UI especifica). Estos hooks solo dependen de React + fetch nativo, asi que van en `core` — son TypeScript puro reutilizable en cualquier proyecto React, no atados a un framework de UI.
|
||||
- **`kind: component` para hooks:** Siguiendo el mismo patron que los hooks Wails en el registry. Un hook React es un componente funcional que tiene estado (`has_state: true`), emite callbacks (`emits`), y declara props. El `kind: component` con `framework: react` es el mapping correcto.
|
||||
- **Tipado generico pero no over-engineered:** Cada hook acepta 1-2 genericos (`T` para data, `TVariables` para inputs). No se introducen abstractions como query keys jerarquicos, cache normalization, ni infinite query cursors tipados. Si se necesita TanStack Query, se instala — estos hooks cubren el 80% sin la complejidad.
|
||||
- **`use_form` minimalista:** No reemplaza a Mantine `useForm` ni a formik/react-hook-form. Es un hook ligero para formularios simples: valores, errores, touched, validacion sincrona, submit. Para formularios complejos (arrays de campos, validacion async, schemas zod) se usa la solucion de Mantine directamente.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Duplicacion con hooks Wails:** Los hooks de datos HTTP y los hooks Wails resuelven el mismo problema con diferente transporte. Mitigado manteniendo interfaces similares (`FetchState` vs `QueryState`, `MutationState` en ambos) para que migrar de uno a otro sea trivial. A futuro se podria abstraer el transporte detras de una interfaz comun, pero por ahora es premature abstraction.
|
||||
- **Cache sin persistencia ni invalidacion cross-tab:** El cache es un `Map` en memoria. Si el usuario abre dos tabs, cada una tiene su cache independiente. Mitigado porque para el 90% de las apps del registry (tools internas, dashboards, admin panels) esto es aceptable. Si se necesita cache persistente o broadcast, se anade como iteracion futura.
|
||||
- **Scope creep hacia framework de fetching:** Estos hooks deben mantenerse simples. Si el uso crece hasta necesitar normalized cache, query deduplication, garbage collection, o suspense — en ese punto se migra a TanStack Query. El valor de estos hooks es zero-dep y cubrir los casos comunes.
|
||||
- **`use_form` puede solaparse con Mantine `useForm`:** Documentar claramente que `use_form` es para formularios simples sin dependencia de Mantine. Si la app ya usa Mantine (que es el caso comun en este registry), `@mantine/form` puede ser mejor opcion. El hook del registry es util cuando se quiere evitar la dependencia de Mantine en un proyecto core.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: api_client
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "apiClient(config: APIClientConfig): { get<T>(path, params?): Promise<T>; post<T>(path, body?): Promise<T>; put<T>(path, body?): Promise<T>; patch<T>(path, body?): Promise<T>; del<T>(path): Promise<T> }"
|
||||
description: "Factory HTTP que retorna un cliente con get/post/put/patch/del. Usa fetch nativo con base_url+headers, parsea JSON automaticamente, llama on_error/on_unauthorized segun status code. Zero dependencias externas."
|
||||
tags: [http, fetch, client, rest, api, factory, core]
|
||||
uses_functions: []
|
||||
uses_types: [APIClientConfig_ts_core]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: config
|
||||
desc: "Configuracion: base_url (URL base sin slash final), headers opcionales, callbacks on_error y on_unauthorized"
|
||||
output: "Objeto con metodos get/post/put/patch/del que hacen fetch con la config aplicada"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/api_client.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { apiClient } from './api_client'
|
||||
|
||||
const api = apiClient({
|
||||
base_url: 'http://localhost:8484/api',
|
||||
headers: { 'X-Client': 'dashboard' },
|
||||
on_unauthorized: () => (window.location.href = '/login'),
|
||||
})
|
||||
|
||||
const users = await api.get<User[]>('/users')
|
||||
const user = await api.post<User>('/users', { name: 'Lucas', email: 'lucas@example.com' })
|
||||
await api.del('/users/123')
|
||||
```
|
||||
|
||||
## Con parametros de query
|
||||
|
||||
```typescript
|
||||
const results = await api.get<User[]>('/users', { search: 'lucas', role: 'admin' })
|
||||
// GET /users?search=lucas&role=admin
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `del` es un alias de DELETE para evitar colision con la keyword `delete` de JS
|
||||
- Si la respuesta no es `application/json`, retorna el texto crudo como `T`
|
||||
- `on_unauthorized` se llama ANTES de `on_error` en status 401
|
||||
- Los errores de red (sin conexion, CORS) tambien llaman a `on_error`
|
||||
- Creado una vez a nivel de modulo/app, pasado a hooks via `fetch_fn`
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { APIClientConfig } from '../../types/core/api_client_config'
|
||||
|
||||
/** Metodos que retorna el factory api_client. */
|
||||
export interface APIClient {
|
||||
get: <T>(path: string, params?: Record<string, string>) => Promise<T>
|
||||
post: <T>(path: string, body?: unknown) => Promise<T>
|
||||
put: <T>(path: string, body?: unknown) => Promise<T>
|
||||
patch: <T>(path: string, body?: unknown) => Promise<T>
|
||||
del: <T>(path: string) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory que crea un cliente HTTP configurado con base_url y headers fijos.
|
||||
* Parsea JSON automaticamente. Llama a on_error/on_unauthorized segun el status.
|
||||
*/
|
||||
export function apiClient(config: APIClientConfig): APIClient {
|
||||
const { base_url, headers: baseHeaders = {}, on_error, on_unauthorized } = config
|
||||
|
||||
const buildUrl = (path: string, params?: Record<string, string>): string => {
|
||||
const url = `${base_url}${path}`
|
||||
if (!params || Object.keys(params).length === 0) return url
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return `${url}?${qs}`
|
||||
}
|
||||
|
||||
const buildHeaders = (extra: Record<string, string> = {}): HeadersInit => ({
|
||||
'Accept': 'application/json',
|
||||
...baseHeaders,
|
||||
...extra,
|
||||
})
|
||||
|
||||
const handleResponse = async <T>(res: Response, url: string, init?: RequestInit): Promise<T> => {
|
||||
if (res.status === 401) {
|
||||
on_unauthorized?.()
|
||||
const err = new Error(`Unauthorized: ${url}`)
|
||||
on_error?.(err, url, init)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
|
||||
on_error?.(err, url, init)
|
||||
throw err
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
return res.text() as unknown as T
|
||||
}
|
||||
|
||||
const request = async <T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string>,
|
||||
): Promise<T> => {
|
||||
const url = buildUrl(path, params)
|
||||
const hasBody = body !== undefined
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: buildHeaders(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
return handleResponse<T>(res, url, init)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('HTTP ')) throw err
|
||||
if (err instanceof Error && err.message.startsWith('Unauthorized')) throw err
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
on_error?.(error, url, init)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T>(path: string, params?: Record<string, string>) =>
|
||||
request<T>('GET', path, undefined, params),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
del: <T>(path: string) => request<T>('DELETE', path),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: http_cache
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "cacheGet<T>(key: string): T | null; cacheSet<T>(key: string, data: T): void; cacheHas(key: string): boolean; cacheIsStale(key: string, staleTime: number): boolean; cacheDelete(key: string): void; cacheInvalidatePrefix(prefix: string): void; cacheClear(): void; cacheSize(): number"
|
||||
description: "Cache in-memory singleton para use_fetch. Map con funciones de get/set/has/isStale/invalidate por prefijo. Compartido entre todas las instancias de use_fetch."
|
||||
tags: [cache, http, fetch, swr, singleton, memory]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: key
|
||||
desc: "Clave de cache (string serializado desde url + params)"
|
||||
- name: staleTime
|
||||
desc: "Tiempo en ms antes de considerar una entrada stale"
|
||||
output: "Modulo con funciones de lectura/escritura/invalidacion del cache in-memory compartido"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/http_cache.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { cacheGet, cacheSet, cacheIsStale } from './http_cache'
|
||||
|
||||
cacheSet('users:list', [{ id: '1', name: 'Lucas' }])
|
||||
const users = cacheGet<User[]>('users:list')
|
||||
const stale = cacheIsStale('users:list', 30_000) // > 30s?
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Singleton de modulo — el `Map` vive mientras dure la sesion de la SPA. No se persiste en localStorage ni IndexedDB. Para invalidacion jerarquica, `cacheInvalidatePrefix('users')` elimina `users`, `users:list`, `users:123`, etc.
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Cache in-memory compartido para use_fetch. Singleton de modulo. */
|
||||
|
||||
interface CacheEntry {
|
||||
data: unknown
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
|
||||
/** Obtener dato del cache. Retorna null si no existe. */
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
const entry = cache.get(key)
|
||||
return entry !== undefined ? (entry.data as T) : null
|
||||
}
|
||||
|
||||
/** Guardar dato en el cache. */
|
||||
export function cacheSet<T>(key: string, data: T): void {
|
||||
cache.set(key, { data, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/** Verificar si existe una entrada en el cache. */
|
||||
export function cacheHas(key: string): boolean {
|
||||
return cache.has(key)
|
||||
}
|
||||
|
||||
/** Verificar si una entrada esta stale (supera staleTime en ms). */
|
||||
export function cacheIsStale(key: string, staleTime: number): boolean {
|
||||
const entry = cache.get(key)
|
||||
if (!entry) return true
|
||||
return Date.now() - entry.timestamp > staleTime
|
||||
}
|
||||
|
||||
/** Eliminar una entrada del cache. */
|
||||
export function cacheDelete(key: string): void {
|
||||
cache.delete(key)
|
||||
}
|
||||
|
||||
/** Eliminar todas las entradas cuya key empiece con prefix. */
|
||||
export function cacheInvalidatePrefix(prefix: string): void {
|
||||
for (const key of cache.keys()) {
|
||||
if (key === prefix || key.startsWith(prefix + ':')) {
|
||||
cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vaciar el cache completo. */
|
||||
export function cacheClear(): void {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
/** Numero de entradas en cache. */
|
||||
export function cacheSize(): number {
|
||||
return cache.size
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: use_debounced_search
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useDebouncedSearch<T>(opts: UseDebouncedSearchOptions<T>): UseDebouncedSearchResult<T>"
|
||||
description: "Hook que combina input con debounce y fetch automatico. Espera N ms de inactividad tras el ultimo cambio antes de fetchear. Expone query, set_query, results, loading, error y clear."
|
||||
tags: [search, debounce, hook, input, fetch, autocomplete, core, component]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: fetch_fn
|
||||
desc: "Funcion que recibe la query string y retorna Promise<T> con los resultados"
|
||||
- name: delay
|
||||
desc: "Ms de inactividad antes de ejecutar el fetch (default 300)"
|
||||
- name: initial_query
|
||||
desc: "Query inicial (default string vacio)"
|
||||
- name: fetch_on_empty
|
||||
desc: "Si hace fetch cuando la query es string vacio (default true)"
|
||||
output: "Objeto con query, set_query, results, loading, error y clear"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_debounced_search.tsx"
|
||||
props:
|
||||
- name: fetch_fn
|
||||
type: "(query: string) => Promise<T>"
|
||||
required: true
|
||||
description: "Funcion de fetch que recibe la query"
|
||||
- name: delay
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ms de debounce (default 300)"
|
||||
- name: initial_query
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Query inicial (default '')"
|
||||
- name: fetch_on_empty
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Fetch cuando query es vacio (default true)"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useDebouncedSearch } from '@/functions/core/use_debounced_search'
|
||||
import { apiClient } from '@/functions/core/api_client'
|
||||
|
||||
const api = apiClient({ base_url: 'http://localhost:8484/api' })
|
||||
|
||||
interface User { id: string; name: string; email: string }
|
||||
|
||||
function UserSearch() {
|
||||
const { query, set_query, results, loading } = useDebouncedSearch<User[]>({
|
||||
fetch_fn: (q) => api.get('/users/search', { q }),
|
||||
delay: 300,
|
||||
fetch_on_empty: false, // No buscar cuando el input esta vacio
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => set_query(e.currentTarget.value)}
|
||||
placeholder="Buscar usuarios..."
|
||||
/>
|
||||
{loading && <span>Buscando...</span>}
|
||||
{results?.map((u) => <div key={u.id}>{u.name}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Cancela el timer anterior en cada keystroke — solo ejecuta el fetch tras N ms de silencio
|
||||
- `clear()` cancela el timer pendiente y limpia estado
|
||||
- `fetch_on_empty: false` es util para autocomplete (no buscar con input vacio)
|
||||
- `fetch_on_empty: true` (default) es util para filtros (mostrar todos sin query)
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface UseDebouncedSearchOptions<T> {
|
||||
/** Funcion de fetch que recibe la query y retorna resultados */
|
||||
fetch_fn: (query: string) => Promise<T>
|
||||
/** Delay en ms antes de ejecutar la busqueda tras el ultimo cambio (default 300) */
|
||||
delay?: number
|
||||
/** Query inicial (default "") */
|
||||
initial_query?: string
|
||||
/** Si false no hace fetch cuando query es empty string (default true = fetch con "") */
|
||||
fetch_on_empty?: boolean
|
||||
}
|
||||
|
||||
export interface UseDebouncedSearchResult<T> {
|
||||
/** Valor actual del input de busqueda */
|
||||
query: string
|
||||
/** Actualizar la query (dispara el debounce) */
|
||||
set_query: (q: string) => void
|
||||
/** Resultados de la ultima busqueda exitosa */
|
||||
results: T | null
|
||||
/** Si hay una busqueda en curso */
|
||||
loading: boolean
|
||||
/** Error si la ultima busqueda fallo */
|
||||
error: Error | null
|
||||
/** Limpiar query y resultados */
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook que combina un input con debounce y fetch automatico.
|
||||
* Espera N ms de inactividad tras el ultimo cambio de query antes de hacer la peticion.
|
||||
*/
|
||||
export function useDebouncedSearch<T>(
|
||||
opts: UseDebouncedSearchOptions<T>,
|
||||
): UseDebouncedSearchResult<T> {
|
||||
const { fetch_fn, delay = 300, initial_query = '', fetch_on_empty = true } = opts
|
||||
|
||||
const [query, setQuery] = useState(initial_query)
|
||||
const [results, setResults] = useState<T | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetch_on_empty && query === '') {
|
||||
setResults(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const data = await fetch_fn(query)
|
||||
if (!mountedRef.current) return
|
||||
setResults(data)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
setError(err instanceof Error ? err : new Error(String(err)))
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false)
|
||||
}
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [query, delay, fetch_on_empty]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setQueryDebounced = useCallback((q: string) => {
|
||||
setQuery(q)
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
setQuery('')
|
||||
setResults(null)
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return { query, set_query: setQueryDebounced, results, loading, error, clear }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: use_fetch
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useFetch<T>(opts: UseFetchOptions<T>): FetchState<T>"
|
||||
description: "Hook GET con cache in-memory stale-while-revalidate. Muestra datos cacheados inmediatamente y refetchea en background. Soporta refetch_interval, refetch_on_focus, stale_time y retry."
|
||||
tags: [fetch, hook, cache, swr, get, http, core, component]
|
||||
uses_functions: [http_cache_ts_core]
|
||||
uses_types: [FetchState_ts_core]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL a fetchear (string). Actua como cache key."
|
||||
- name: fetch_fn
|
||||
desc: "Funcion de fetch alternativa que recibe la URL (ej: api.get de api_client). Si se omite usa fetch nativo."
|
||||
- name: enabled
|
||||
desc: "Si false no hace fetch automatico al montar (default true)"
|
||||
- name: stale_time
|
||||
desc: "Tiempo en ms antes de considerar datos stale y refetchear (default 30000)"
|
||||
- name: refetch_interval
|
||||
desc: "Intervalo en ms para refetch automatico. 0 desactiva (default 0)"
|
||||
- name: refetch_on_focus
|
||||
desc: "Si refetchea al recuperar foco de la ventana (default true)"
|
||||
- name: retry
|
||||
desc: "Numero de reintentos ante error (default 0)"
|
||||
- name: retry_delay
|
||||
desc: "Delay en ms entre reintentos (default 1000)"
|
||||
output: "FetchState<T> con data, error, loading, is_stale y refetch"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_fetch.tsx"
|
||||
props:
|
||||
- name: url
|
||||
type: "string"
|
||||
required: false
|
||||
description: "URL a fetchear"
|
||||
- name: fetch_fn
|
||||
type: "(url: string) => Promise<T>"
|
||||
required: false
|
||||
description: "Funcion de fetch alternativa (ej: api.get)"
|
||||
- name: enabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Habilitar fetch automatico (default true)"
|
||||
- name: stale_time
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ms antes de considerar datos stale (default 30000)"
|
||||
- name: refetch_interval
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ms entre refetches automaticos (0 = off)"
|
||||
- name: refetch_on_focus
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Refetch al recuperar foco (default true)"
|
||||
- name: retry
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Reintentos ante error (default 0)"
|
||||
- name: retry_delay
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ms entre reintentos (default 1000)"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useFetch } from '@/functions/core/use_fetch'
|
||||
import { apiClient } from '@/functions/core/api_client'
|
||||
|
||||
const api = apiClient({ base_url: 'http://localhost:8484/api' })
|
||||
|
||||
function UsersList() {
|
||||
const { data: users, loading, error, is_stale, refetch } = useFetch<User[]>({
|
||||
url: '/users',
|
||||
fetch_fn: api.get,
|
||||
stale_time: 10_000,
|
||||
refetch_on_focus: true,
|
||||
})
|
||||
|
||||
if (loading && !users) return <div>Cargando...</div>
|
||||
if (error) return <div>Error: {error.message}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
{is_stale && <span>Actualizando...</span>}
|
||||
{users?.map((u) => <div key={u.id}>{u.name}</div>)}
|
||||
<button onClick={refetch}>Refrescar</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- La URL actua como cache key — misma URL = mismos datos cacheados
|
||||
- Stale-while-revalidate: si hay datos en cache y son stale, los muestra (`is_stale: true`) y refetchea en background
|
||||
- Si no se pasa `fetch_fn`, usa `fetch` nativo con `Accept: application/json`
|
||||
- Usar `enabled: false` para fetch condicional (ej: cuando falta un parametro)
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cacheGet, cacheHas, cacheIsStale, cacheSet } from './http_cache'
|
||||
import type { FetchState } from '../../types/core/fetch_state'
|
||||
|
||||
export interface UseFetchOptions<T> {
|
||||
/** URL a fetchear, o funcion que retorna una Promise con los datos */
|
||||
url?: string
|
||||
/** Funcion de fetch alternativa (ej: api.get de api_client). Recibe la URL. */
|
||||
fetch_fn?: (url: string) => Promise<T>
|
||||
/** Si false, no hace fetch automatico al montar (default true) */
|
||||
enabled?: boolean
|
||||
/** Tiempo en ms antes de considerar datos stale (default 30000) */
|
||||
stale_time?: number
|
||||
/** Intervalo en ms para refetch automatico (0 = desactivado) */
|
||||
refetch_interval?: number
|
||||
/** Si refetchea al recuperar foco de la ventana (default true) */
|
||||
refetch_on_focus?: boolean
|
||||
/** Numero de reintentos ante error (default 0) */
|
||||
retry?: number
|
||||
/** Delay en ms entre reintentos (default 1000) */
|
||||
retry_delay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook GET con cache in-memory stale-while-revalidate.
|
||||
* Muestra datos cacheados inmediatamente mientras refetchea en background.
|
||||
*/
|
||||
export function useFetch<T>(opts: UseFetchOptions<T>): FetchState<T> {
|
||||
const {
|
||||
url = '',
|
||||
fetch_fn,
|
||||
enabled = true,
|
||||
stale_time = 30_000,
|
||||
refetch_interval = 0,
|
||||
refetch_on_focus = true,
|
||||
retry = 0,
|
||||
retry_delay = 1000,
|
||||
} = opts
|
||||
|
||||
const cacheKey = url
|
||||
|
||||
type FetchInternalState = Omit<FetchState<T>, 'refetch'>
|
||||
|
||||
const [state, setState] = useState<FetchInternalState>(() => ({
|
||||
data: cacheHas(cacheKey) ? cacheGet<T>(cacheKey) : null,
|
||||
error: null,
|
||||
loading: enabled && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time)),
|
||||
is_stale: cacheHas(cacheKey) && cacheIsStale(cacheKey, stale_time),
|
||||
}))
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
const retryCountRef = useRef(0)
|
||||
|
||||
const doFetch = useCallback(async (): Promise<T> => {
|
||||
if (!mountedRef.current) throw new Error('unmounted')
|
||||
|
||||
setState((s: FetchInternalState) => ({ ...s, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const data = fetch_fn
|
||||
? await fetch_fn(url)
|
||||
: await fetch(url, { headers: { Accept: 'application/json' } }).then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<T>
|
||||
})
|
||||
|
||||
if (!mountedRef.current) throw new Error('unmounted')
|
||||
|
||||
cacheSet(cacheKey, data)
|
||||
retryCountRef.current = 0
|
||||
|
||||
setState({ data, loading: false, error: null, is_stale: false })
|
||||
return data
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) throw err
|
||||
|
||||
if (retryCountRef.current < retry) {
|
||||
retryCountRef.current += 1
|
||||
await new Promise((resolve) => setTimeout(resolve, retry_delay))
|
||||
return doFetch()
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setState((s: FetchInternalState) => ({ ...s, loading: false, error }))
|
||||
throw error
|
||||
}
|
||||
}, [url, fetch_fn, cacheKey, stale_time, retry, retry_delay])
|
||||
|
||||
// Fetch inicial
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (enabled && url && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time))) {
|
||||
doFetch().catch(() => {})
|
||||
} else if (enabled && url && cacheHas(cacheKey)) {
|
||||
// Datos en cache no stale — actualizar estado si es necesario
|
||||
setState((s: FetchInternalState) => ({
|
||||
...s,
|
||||
data: cacheGet<T>(cacheKey),
|
||||
loading: false,
|
||||
is_stale: false,
|
||||
}))
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [enabled, url, stale_time]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Refetch por intervalo
|
||||
useEffect(() => {
|
||||
if (!refetch_interval || !enabled || !url) return
|
||||
|
||||
const id = setInterval(() => {
|
||||
doFetch().catch(() => {})
|
||||
}, refetch_interval)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [refetch_interval, enabled, url, doFetch])
|
||||
|
||||
// Refetch al recuperar foco
|
||||
useEffect(() => {
|
||||
if (!refetch_on_focus || !enabled || !url) return
|
||||
|
||||
const handler = () => {
|
||||
if (cacheIsStale(cacheKey, stale_time)) {
|
||||
doFetch().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handler)
|
||||
return () => window.removeEventListener('focus', handler)
|
||||
}, [refetch_on_focus, enabled, url, cacheKey, stale_time, doFetch])
|
||||
|
||||
return { ...state, refetch: doFetch }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: use_form
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useForm<T extends Record<string, unknown>>(opts: UseFormOptions<T>): UseFormResult<T>"
|
||||
description: "Hook de formulario con validacion sincrona, touched tracking y submit handler. Minimalista: set_field, set_fields, validate, submit, reset. Para formularios simples sin dependencia de Mantine."
|
||||
tags: [form, hook, validation, touched, submit, core, component]
|
||||
uses_functions: []
|
||||
uses_types: [FormState_ts_core]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: initial_values
|
||||
desc: "Valores iniciales del formulario tipados con T"
|
||||
- name: validate
|
||||
desc: "Funcion de validacion sincrona: recibe values, retorna errores por campo (undefined = campo valido)"
|
||||
output: "UseFormResult<T> con values, errors, touched, is_valid, is_submitting, set_field, set_fields, validate, submit y reset"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_form.tsx"
|
||||
props:
|
||||
- name: initial_values
|
||||
type: "T"
|
||||
required: true
|
||||
description: "Valores iniciales del formulario"
|
||||
- name: validate
|
||||
type: "(values: T) => Partial<Record<keyof T, string | undefined>>"
|
||||
required: false
|
||||
description: "Funcion de validacion sincrona"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useForm } from '@/functions/core/use_form'
|
||||
|
||||
interface LoginForm {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const form = useForm<LoginForm>({
|
||||
initial_values: { email: '', password: '' },
|
||||
validate: (values) => ({
|
||||
email: !values.email.includes('@') ? 'Email invalido' : undefined,
|
||||
password: values.password.length < 8 ? 'Minimo 8 caracteres' : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
form.submit(async (values) => {
|
||||
await loginApi(values.email, values.password)
|
||||
})
|
||||
}}>
|
||||
<input
|
||||
value={form.values.email}
|
||||
onChange={(e) => form.set_field('email', e.currentTarget.value)}
|
||||
/>
|
||||
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
value={form.values.password}
|
||||
onChange={(e) => form.set_field('password', e.currentTarget.value)}
|
||||
/>
|
||||
{form.touched.password && form.errors.password && <span>{form.errors.password}</span>}
|
||||
|
||||
<button type="submit" disabled={form.is_submitting || !form.is_valid}>
|
||||
{form.is_submitting ? 'Cargando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `touched` permite mostrar errores solo despues de que el usuario modifica un campo
|
||||
- `submit(handler)` llama a `validate()` primero y solo ejecuta el handler si pasa
|
||||
- `is_valid` es true si no hay errores (incluso antes de tocar campos)
|
||||
- Para formularios complejos con validacion async, arrays de campos o schemas zod, usar `@mantine/form`
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { FormState } from '../../types/core/form_state'
|
||||
|
||||
export interface UseFormOptions<T extends Record<string, unknown>> {
|
||||
/** Valores iniciales del formulario */
|
||||
initial_values: T
|
||||
/** Funcion de validacion sincrona. Retorna errores por campo (undefined = sin error). */
|
||||
validate?: (values: T) => Partial<Record<keyof T, string | undefined>>
|
||||
}
|
||||
|
||||
/** Resultado del hook use_form, extiende FormState con metodos de control */
|
||||
export interface UseFormResult<T extends Record<string, unknown>> extends FormState<T> {
|
||||
/** Actualizar un campo individual */
|
||||
set_field: <K extends keyof T>(name: K, value: T[K]) => void
|
||||
/** Actualizar multiples campos a la vez */
|
||||
set_fields: (partial: Partial<T>) => void
|
||||
/** Ejecutar validacion manualmente. Retorna true si pasa. */
|
||||
validate: () => boolean
|
||||
/** Ejecutar validacion y llamar al handler solo si pasa */
|
||||
submit: (handler: (values: T) => Promise<void> | void) => Promise<void>
|
||||
/** Resetear al estado inicial */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para formularios con validacion sincrona, touched tracking y submit handler.
|
||||
* Minimalista: no reemplaza @mantine/form. Para formularios simples sin Mantine.
|
||||
*/
|
||||
export function useForm<T extends Record<string, unknown>>(
|
||||
opts: UseFormOptions<T>,
|
||||
): UseFormResult<T> {
|
||||
const { initial_values, validate: validateFn } = opts
|
||||
|
||||
const [values, setValues] = useState<T>(initial_values)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const runValidation = useCallback(
|
||||
(vals: T): Partial<Record<keyof T, string>> => {
|
||||
if (!validateFn) return {}
|
||||
const result = validateFn(vals)
|
||||
const filtered: Partial<Record<keyof T, string>> = {}
|
||||
for (const key in result) {
|
||||
const val = result[key as keyof T]
|
||||
if (val !== undefined) {
|
||||
filtered[key as keyof T] = val
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
},
|
||||
[validateFn],
|
||||
)
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof T>(name: K, value: T[K]) => {
|
||||
setValues((prev: T) => {
|
||||
const next = { ...prev, [name]: value }
|
||||
if (validateFn) {
|
||||
const newErrors = runValidation(next)
|
||||
setErrors(newErrors)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setTouched((prev: Partial<Record<keyof T, boolean>>) => ({ ...prev, [name]: true }))
|
||||
},
|
||||
[validateFn, runValidation],
|
||||
)
|
||||
|
||||
const setFields = useCallback(
|
||||
(partial: Partial<T>) => {
|
||||
setValues((prev: T) => {
|
||||
const next = { ...prev, ...partial }
|
||||
if (validateFn) {
|
||||
const newErrors = runValidation(next)
|
||||
setErrors(newErrors)
|
||||
}
|
||||
return next
|
||||
})
|
||||
const newTouched: Partial<Record<keyof T, boolean>> = {}
|
||||
for (const key in partial) {
|
||||
newTouched[key as keyof T] = true
|
||||
}
|
||||
setTouched((prev: Partial<Record<keyof T, boolean>>) => ({ ...prev, ...newTouched }))
|
||||
},
|
||||
[validateFn, runValidation],
|
||||
)
|
||||
|
||||
const validate = useCallback((): boolean => {
|
||||
const newErrors = runValidation(values)
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [values, runValidation])
|
||||
|
||||
const submit = useCallback(
|
||||
async (handler: (values: T) => Promise<void> | void): Promise<void> => {
|
||||
const newErrors = runValidation(values)
|
||||
setErrors(newErrors)
|
||||
|
||||
if (Object.keys(newErrors).length > 0) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await handler(values)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[values, runValidation],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValues(initial_values)
|
||||
setErrors({})
|
||||
setTouched({})
|
||||
setIsSubmitting(false)
|
||||
}, [initial_values])
|
||||
|
||||
const isValid = Object.keys(errors).length === 0
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
is_valid: isValid,
|
||||
is_submitting: isSubmitting,
|
||||
set_field: setField,
|
||||
set_fields: setFields,
|
||||
validate,
|
||||
submit,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: use_infinite_scroll
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useInfiniteScroll<T>(opts: UseInfiniteScrollOptions<T>): UseInfiniteScrollResult<T>"
|
||||
description: "Hook de paginacion infinita cursor-based y offset-based. Concatena paginas en array plano. Expone load_more, has_more y reset. Zero dependencias externas."
|
||||
tags: [infinite, scroll, pagination, cursor, hook, http, core, component]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: fetch_fn
|
||||
desc: "Funcion que recibe el cursor actual (string, number o null) y retorna Promise<{items, next_cursor}>"
|
||||
- name: initial_cursor
|
||||
desc: "Cursor inicial, null para empezar desde el principio (default null)"
|
||||
- name: enabled
|
||||
desc: "Si hace fetch automatico al montar (default true)"
|
||||
output: "Objeto con data (array concatenado), loading, error, has_more, load_more() y reset()"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_infinite_scroll.tsx"
|
||||
props:
|
||||
- name: fetch_fn
|
||||
type: "(cursor: string | number | null) => Promise<InfiniteScrollPage<T>>"
|
||||
required: true
|
||||
description: "Funcion de fetch que recibe cursor y retorna pagina con items y next_cursor"
|
||||
- name: initial_cursor
|
||||
desc: "Cursor inicial (default null)"
|
||||
type: "string | number | null"
|
||||
required: false
|
||||
description: "Cursor/offset inicial"
|
||||
- name: enabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Fetch automatico al montar (default true)"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo cursor-based
|
||||
|
||||
```tsx
|
||||
import { useInfiniteScroll } from '@/functions/core/use_infinite_scroll'
|
||||
import { apiClient } from '@/functions/core/api_client'
|
||||
|
||||
const api = apiClient({ base_url: 'http://localhost:8484/api' })
|
||||
|
||||
function EventsList() {
|
||||
const { data, loading, has_more, load_more } = useInfiniteScroll<Event>({
|
||||
fetch_fn: async (cursor) => {
|
||||
const res = await api.get<{ items: Event[]; next_cursor: string | null }>(
|
||||
'/events',
|
||||
cursor ? { after: String(cursor) } : undefined,
|
||||
)
|
||||
return { items: res.items, next_cursor: res.next_cursor }
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.map((e) => <div key={e.id}>{e.name}</div>)}
|
||||
{has_more && (
|
||||
<button disabled={loading} onClick={load_more}>
|
||||
{loading ? 'Cargando...' : 'Cargar mas'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Ejemplo offset-based
|
||||
|
||||
```tsx
|
||||
const { data, load_more, has_more } = useInfiniteScroll<Product>({
|
||||
fetch_fn: async (cursor) => {
|
||||
const page = typeof cursor === 'number' ? cursor : 0
|
||||
const items = await api.get<Product[]>('/products', {
|
||||
offset: String(page * 20),
|
||||
limit: '20',
|
||||
})
|
||||
return {
|
||||
items,
|
||||
next_cursor: items.length === 20 ? page + 1 : null,
|
||||
}
|
||||
},
|
||||
initial_cursor: 0,
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `InfiniteScrollPage.next_cursor = null` indica que no hay mas paginas
|
||||
- `reset()` vuelve al cursor inicial y refetchea desde el principio
|
||||
- `load_more` es no-op si `loading` o `!has_more`
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
export interface UseInfiniteScrollOptions<T> {
|
||||
/** Funcion de fetch que recibe el cursor/pagina actual y retorna la pagina de datos */
|
||||
fetch_fn: (cursor: string | number | null) => Promise<InfiniteScrollPage<T>>
|
||||
/** Cursor inicial (null para empezar desde el principio) */
|
||||
initial_cursor?: string | number | null
|
||||
/** Si el primer fetch es automatico al montar (default true) */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** Pagina retornada por fetch_fn */
|
||||
export interface InfiniteScrollPage<T> {
|
||||
/** Datos de esta pagina */
|
||||
items: T[]
|
||||
/** Cursor para la siguiente pagina. null/undefined = no hay mas paginas. */
|
||||
next_cursor?: string | number | null
|
||||
}
|
||||
|
||||
/** Estado del hook de paginacion infinita */
|
||||
export interface UseInfiniteScrollResult<T> {
|
||||
/** Todos los items concatenados de todas las paginas */
|
||||
data: T[]
|
||||
/** Si hay una peticion en curso */
|
||||
loading: boolean
|
||||
/** Error si la ultima peticion fallo */
|
||||
error: Error | null
|
||||
/** Si hay mas paginas disponibles */
|
||||
has_more: boolean
|
||||
/** Cargar la siguiente pagina */
|
||||
load_more: () => void
|
||||
/** Resetear al estado inicial y refetchear desde el principio */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para paginacion infinita cursor-based y offset-based.
|
||||
* Concatena paginas en un solo array. Expone load_more, has_more y reset.
|
||||
*/
|
||||
export function useInfiniteScroll<T>(
|
||||
opts: UseInfiniteScrollOptions<T>,
|
||||
): UseInfiniteScrollResult<T> {
|
||||
const { fetch_fn, initial_cursor = null, enabled = true } = opts
|
||||
|
||||
const [data, setData] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(enabled)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
|
||||
const cursorRef = useRef<string | number | null>(initial_cursor)
|
||||
const loadingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (cursor: string | number | null): Promise<void> => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const page = await fetch_fn(cursor)
|
||||
|
||||
if (!mountedRef.current) return
|
||||
|
||||
setData((prev: T[]) => (cursor === initial_cursor ? page.items : [...prev, ...page.items]))
|
||||
|
||||
const nextCursor = page.next_cursor ?? null
|
||||
cursorRef.current = nextCursor
|
||||
setHasMore(nextCursor !== null && nextCursor !== undefined)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
setError(err instanceof Error ? err : new Error(String(err)))
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false)
|
||||
loadingRef.current = false
|
||||
}
|
||||
},
|
||||
[fetch_fn, initial_cursor],
|
||||
)
|
||||
|
||||
// Fetch inicial
|
||||
const initializedRef = useRef(false)
|
||||
if (!initializedRef.current && enabled) {
|
||||
initializedRef.current = true
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
fetchPage(initial_cursor)
|
||||
}
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || loadingRef.current) return
|
||||
fetchPage(cursorRef.current).catch(() => {})
|
||||
}, [fetchPage, hasMore])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cursorRef.current = initial_cursor
|
||||
loadingRef.current = false
|
||||
setData([])
|
||||
setError(null)
|
||||
setHasMore(true)
|
||||
fetchPage(initial_cursor).catch(() => {})
|
||||
}, [fetchPage, initial_cursor])
|
||||
|
||||
return { data, loading, error, has_more: hasMore, load_more: loadMore, reset }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: use_mutation
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useMutation<TData, TVariables>(opts: UseMutationOptions<TData, TVariables>): MutationState<TData, TVariables>"
|
||||
description: "Hook POST/PUT/DELETE con optimistic updates, invalidacion de cache, retry y callbacks completos. No ejecuta al montar — expone mutate y mutate_async."
|
||||
tags: [mutation, hook, post, put, delete, http, optimistic, cache, core, component]
|
||||
uses_functions: [http_cache_ts_core]
|
||||
uses_types: [MutationState_ts_core]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: mutation_fn
|
||||
desc: "Funcion que ejecuta la mutacion, recibe variables y retorna Promise<TData>"
|
||||
- name: on_mutate
|
||||
desc: "Callback antes de la mutacion para optimistic updates. Retorna contexto de rollback."
|
||||
- name: on_success
|
||||
desc: "Callback al exito con data, variables y context"
|
||||
- name: on_error
|
||||
desc: "Callback al error con error, variables y context para rollback"
|
||||
- name: on_settled
|
||||
desc: "Callback siempre (exito o error)"
|
||||
- name: invalidate_cache
|
||||
desc: "Prefijos de cache a invalidar en exito (ej: 'users' invalida users, users:list, etc.)"
|
||||
- name: retry
|
||||
desc: "Numero de reintentos ante error (default 0)"
|
||||
- name: retry_delay
|
||||
desc: "Delay en ms entre reintentos (default 1000)"
|
||||
output: "MutationState<TData, TVariables> con mutate, mutate_async, data, error, loading y reset"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_mutation.tsx"
|
||||
props:
|
||||
- name: mutation_fn
|
||||
type: "(variables: TVariables) => Promise<TData>"
|
||||
required: true
|
||||
description: "Funcion que ejecuta la mutacion"
|
||||
- name: on_mutate
|
||||
type: "(variables: TVariables) => Promise<unknown> | unknown"
|
||||
required: false
|
||||
description: "Optimistic update antes de la mutacion"
|
||||
- name: on_success
|
||||
type: "(data: TData, variables: TVariables, context: unknown) => void"
|
||||
required: false
|
||||
description: "Callback al exito"
|
||||
- name: on_error
|
||||
type: "(error: Error, variables: TVariables, context: unknown) => void"
|
||||
required: false
|
||||
description: "Callback al error (para rollback de optimistic updates)"
|
||||
- name: invalidate_cache
|
||||
type: "string[]"
|
||||
required: false
|
||||
description: "Prefijos de cache a invalidar en exito"
|
||||
- name: retry
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Reintentos ante error (default 0)"
|
||||
- name: retry_delay
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Ms entre reintentos (default 1000)"
|
||||
emits: [on_success, on_error, on_settled]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useMutation } from '@/functions/core/use_mutation'
|
||||
import { apiClient } from '@/functions/core/api_client'
|
||||
|
||||
const api = apiClient({ base_url: 'http://localhost:8484/api' })
|
||||
|
||||
function DeleteButton({ userId }: { userId: string }) {
|
||||
const { mutate: deleteUser, loading } = useMutation<void, string>({
|
||||
mutation_fn: (id) => api.del(`/users/${id}`),
|
||||
invalidate_cache: ['users'],
|
||||
on_success: () => console.log('Eliminado'),
|
||||
})
|
||||
|
||||
return (
|
||||
<button disabled={loading} onClick={() => deleteUser(userId)}>
|
||||
{loading ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Optimistic update
|
||||
|
||||
```tsx
|
||||
const { mutate } = useMutation<User, Partial<User>>({
|
||||
mutation_fn: (data) => api.post('/users', data),
|
||||
on_mutate: (newUser) => {
|
||||
// Guardar snapshot para rollback
|
||||
const prev = cacheGet<User[]>('users')
|
||||
cacheSet('users', [...(prev ?? []), { ...newUser, id: 'temp' }])
|
||||
return { prev }
|
||||
},
|
||||
on_error: (_err, _vars, context) => {
|
||||
// Rollback
|
||||
const ctx = context as { prev: User[] }
|
||||
cacheSet('users', ctx.prev)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `mutate` es fire-and-forget (no lanza, silencia el error)
|
||||
- `mutate_async` retorna Promise (puede rechazar — usar try/catch)
|
||||
- `invalidate_cache` usa `cacheInvalidatePrefix` — invalida prefijos jerarquicos
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { cacheInvalidatePrefix } from './http_cache'
|
||||
import type { MutationState } from '../../types/core/mutation_state'
|
||||
|
||||
export interface UseMutationOptions<TData, TVariables> {
|
||||
/** Funcion que ejecuta la mutacion (POST/PUT/DELETE) */
|
||||
mutation_fn: (variables: TVariables) => Promise<TData>
|
||||
/** Callback antes de la mutacion — permite optimistic updates. Retorna contexto de rollback. */
|
||||
on_mutate?: (variables: TVariables) => Promise<unknown> | unknown
|
||||
/** Callback al exito */
|
||||
on_success?: (data: TData, variables: TVariables, context: unknown) => void
|
||||
/** Callback al error */
|
||||
on_error?: (error: Error, variables: TVariables, context: unknown) => void
|
||||
/** Callback siempre (exito o error) */
|
||||
on_settled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
|
||||
/** Prefijos de cache a invalidar en exito (ej: ['users'] invalida users, users:list, etc.) */
|
||||
invalidate_cache?: string[]
|
||||
/** Numero de reintentos ante error (default 0) */
|
||||
retry?: number
|
||||
/** Delay en ms entre reintentos (default 1000) */
|
||||
retry_delay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para operaciones de escritura POST/PUT/DELETE.
|
||||
* No ejecuta al montar. Expone mutate() y mutate_async().
|
||||
* Soporta optimistic updates via on_mutate y invalidacion de cache.
|
||||
*/
|
||||
export function useMutation<TData, TVariables = void>(
|
||||
opts: UseMutationOptions<TData, TVariables>,
|
||||
): MutationState<TData, TVariables> {
|
||||
const {
|
||||
mutation_fn,
|
||||
on_mutate,
|
||||
on_success,
|
||||
on_error,
|
||||
on_settled,
|
||||
invalidate_cache = [],
|
||||
retry = 0,
|
||||
retry_delay = 1000,
|
||||
} = opts
|
||||
|
||||
type MutationInternalState = {
|
||||
status: 'idle' | 'loading' | 'success' | 'error'
|
||||
data: TData | null
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
const [state, setState] = useState<MutationInternalState>({ status: 'idle', data: null, error: null })
|
||||
|
||||
const retryCountRef = useRef(0)
|
||||
|
||||
const execute = useCallback(
|
||||
async (variables: TVariables): Promise<TData> => {
|
||||
setState((s: MutationInternalState) => ({ ...s, status: 'loading', error: null }))
|
||||
|
||||
let context: unknown
|
||||
|
||||
try {
|
||||
if (on_mutate) {
|
||||
context = await on_mutate(variables)
|
||||
}
|
||||
|
||||
const data = await mutation_fn(variables)
|
||||
|
||||
retryCountRef.current = 0
|
||||
setState({ status: 'success', data, error: null })
|
||||
|
||||
// Invalidar cache en exito
|
||||
for (const prefix of invalidate_cache) {
|
||||
cacheInvalidatePrefix(prefix)
|
||||
}
|
||||
|
||||
on_success?.(data, variables, context)
|
||||
on_settled?.(data, null, variables, context)
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
if (retryCountRef.current < retry) {
|
||||
retryCountRef.current += 1
|
||||
await new Promise((resolve) => setTimeout(resolve, retry_delay))
|
||||
return execute(variables)
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setState((s: MutationInternalState) => ({ ...s, status: 'error', error }))
|
||||
|
||||
on_error?.(error, variables, context)
|
||||
on_settled?.(undefined, error, variables, context)
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[mutation_fn, on_mutate, on_success, on_error, on_settled, invalidate_cache, retry, retry_delay],
|
||||
)
|
||||
|
||||
const mutate = useCallback(
|
||||
(variables: TVariables) => {
|
||||
execute(variables).catch(() => {})
|
||||
},
|
||||
[execute],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ status: 'idle', data: null, error: null })
|
||||
retryCountRef.current = 0
|
||||
}, [])
|
||||
|
||||
return {
|
||||
mutate,
|
||||
mutate_async: execute,
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
loading: state.status === 'loading',
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: use_sse
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useSSE<T>(opts: UseSSEOptions<T>): UseSSEResult<T>"
|
||||
description: "Hook para Server-Sent Events con auto-reconexion y backoff exponencial. Parsea eventos como JSON. Acumula todos los eventos en data[]. Expone last_event, status y close."
|
||||
tags: [sse, server-sent-events, stream, hook, realtime, reconnect, core, component]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL del endpoint SSE (ej: http://localhost:8484/api/logs/stream)"
|
||||
- name: enabled
|
||||
desc: "Si conecta automaticamente al montar (default true)"
|
||||
- name: reconnect
|
||||
desc: "Si reconecta automaticamente con backoff exponencial (default true)"
|
||||
- name: reconnect_interval
|
||||
desc: "Delay inicial de reconexion en ms (default 1000). Se duplica hasta max_reconnect_interval."
|
||||
- name: max_reconnect_interval
|
||||
desc: "Delay maximo de reconexion en ms (default 30000)"
|
||||
- name: event_name
|
||||
desc: "Nombre del evento SSE a escuchar (default 'message')"
|
||||
- name: on_event
|
||||
desc: "Callback por cada evento recibido con el dato parseado"
|
||||
output: "Objeto con data[], last_event, status (connecting/open/closed), error, close() y connect()"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_sse.tsx"
|
||||
props:
|
||||
- name: url
|
||||
type: "string"
|
||||
required: true
|
||||
description: "URL del endpoint SSE"
|
||||
- name: enabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Conectar al montar (default true)"
|
||||
- name: reconnect
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Auto-reconectar (default true)"
|
||||
- name: reconnect_interval
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Delay inicial reconexion en ms (default 1000)"
|
||||
- name: max_reconnect_interval
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Delay maximo reconexion en ms (default 30000)"
|
||||
- name: event_name
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Nombre evento SSE (default 'message')"
|
||||
emits: [on_event, on_error]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useSSE } from '@/functions/core/use_sse'
|
||||
|
||||
interface LogEntry { level: string; msg: string; ts: number }
|
||||
|
||||
function LogViewer() {
|
||||
const { data: logs, last_event, status, close } = useSSE<LogEntry>({
|
||||
url: 'http://localhost:8484/api/logs/stream',
|
||||
reconnect: true,
|
||||
reconnect_interval: 2000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>Status: {status}</span>
|
||||
<div style={{ overflow: 'auto', height: 400 }}>
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} style={{ color: log.level === 'error' ? 'red' : 'inherit' }}>
|
||||
[{log.level}] {log.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={close}>Cerrar conexion</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El backoff exponencial duplica el delay hasta `max_reconnect_interval`
|
||||
- Si el evento no es JSON valido, se guarda como string
|
||||
- `close()` previene reconexion automatica
|
||||
- `connect()` fuerza reconexion manual aunque haya sido cerrado manualmente
|
||||
- `data` acumula todos los eventos de la sesion. Para limpiar, desmontar y remontar el componente.
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type SSEStatus = 'connecting' | 'open' | 'closed'
|
||||
|
||||
export interface UseSSEOptions<T> {
|
||||
/** URL del endpoint SSE */
|
||||
url: string
|
||||
/** Si conecta automaticamente al montar (default true) */
|
||||
enabled?: boolean
|
||||
/** Si reconecta automaticamente al perder la conexion (default true) */
|
||||
reconnect?: boolean
|
||||
/** Delay inicial de reconexion en ms (default 1000). Se duplica con backoff hasta max_reconnect_interval. */
|
||||
reconnect_interval?: number
|
||||
/** Delay maximo de reconexion en ms (default 30000) */
|
||||
max_reconnect_interval?: number
|
||||
/** Nombre del evento a escuchar (default 'message'). Para multiples eventos usar on_event. */
|
||||
event_name?: string
|
||||
/** Callback por evento recibido. Recibe el dato parseado. */
|
||||
on_event?: (data: T) => void
|
||||
/** Callback al error */
|
||||
on_error?: (error: Event) => void
|
||||
}
|
||||
|
||||
export interface UseSSEResult<T> {
|
||||
/** Todos los eventos recibidos en esta sesion */
|
||||
data: T[]
|
||||
/** Ultimo evento recibido */
|
||||
last_event: T | null
|
||||
/** Estado de la conexion */
|
||||
status: SSEStatus
|
||||
/** Error de conexion si ocurrio */
|
||||
error: Event | null
|
||||
/** Cerrar la conexion manualmente */
|
||||
close: () => void
|
||||
/** Reconectar manualmente */
|
||||
connect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para Server-Sent Events con auto-reconexion y backoff exponencial.
|
||||
* Parsea cada evento como JSON. Acumula todos los eventos en data[].
|
||||
*/
|
||||
export function useSSE<T>(opts: UseSSEOptions<T>): UseSSEResult<T> {
|
||||
const {
|
||||
url,
|
||||
enabled = true,
|
||||
reconnect = true,
|
||||
reconnect_interval = 1000,
|
||||
max_reconnect_interval = 30_000,
|
||||
event_name = 'message',
|
||||
on_event,
|
||||
on_error,
|
||||
} = opts
|
||||
|
||||
const [data, setData] = useState<T[]>([])
|
||||
const [lastEvent, setLastEvent] = useState<T | null>(null)
|
||||
const [status, setStatus] = useState<SSEStatus>('closed')
|
||||
const [error, setError] = useState<Event | null>(null)
|
||||
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const currentIntervalRef = useRef(reconnect_interval)
|
||||
const mountedRef = useRef(true)
|
||||
const closedManuallyRef = useRef(false)
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// Cerrar conexion previa si existe
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
|
||||
closedManuallyRef.current = false
|
||||
currentIntervalRef.current = reconnect_interval
|
||||
|
||||
setStatus('connecting')
|
||||
setError(null)
|
||||
|
||||
const es = new EventSource(url)
|
||||
esRef.current = es
|
||||
|
||||
es.addEventListener(event_name, (e: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
let parsed: T
|
||||
try {
|
||||
parsed = JSON.parse(e.data) as T
|
||||
} catch {
|
||||
parsed = e.data as unknown as T
|
||||
}
|
||||
|
||||
setData((prev: T[]) => [...prev, parsed])
|
||||
setLastEvent(parsed)
|
||||
on_event?.(parsed)
|
||||
// Reset backoff on successful message
|
||||
currentIntervalRef.current = reconnect_interval
|
||||
})
|
||||
|
||||
es.onopen = () => {
|
||||
if (!mountedRef.current) return
|
||||
setStatus('open')
|
||||
currentIntervalRef.current = reconnect_interval
|
||||
}
|
||||
|
||||
es.onerror = (e) => {
|
||||
if (!mountedRef.current) return
|
||||
setError(e)
|
||||
on_error?.(e)
|
||||
setStatus('closed')
|
||||
es.close()
|
||||
esRef.current = null
|
||||
|
||||
if (reconnect && !closedManuallyRef.current) {
|
||||
const delay = Math.min(currentIntervalRef.current, max_reconnect_interval)
|
||||
currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval)
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current && !closedManuallyRef.current) {
|
||||
connect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}, [url, event_name, reconnect, reconnect_interval, max_reconnect_interval, on_event, on_error])
|
||||
|
||||
const close = useCallback(() => {
|
||||
closedManuallyRef.current = true
|
||||
clearReconnectTimer()
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
setStatus('closed')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (enabled) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearReconnectTimer()
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
}
|
||||
}, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { data, last_event: lastEvent, status, error, close, connect }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: use_websocket
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "useWebSocket<TSend, TRecv>(opts: UseWebSocketOptions<TSend, TRecv>): UseWebSocketResult<TSend, TRecv>"
|
||||
description: "Hook WebSocket con auto-reconexion y backoff exponencial. Serializa envios como JSON, parsea recepcion como JSON. Buffer de mensajes con limite configurable. Mensajes en vuelo se bufferean hasta reconectar."
|
||||
tags: [websocket, ws, hook, realtime, reconnect, stream, core, component]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [react]
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL del WebSocket (ej: ws://localhost:8484/ws/chat)"
|
||||
- name: enabled
|
||||
desc: "Si conecta automaticamente al montar (default true)"
|
||||
- name: reconnect
|
||||
desc: "Si reconecta automaticamente con backoff exponencial (default true)"
|
||||
- name: reconnect_interval
|
||||
desc: "Delay inicial de reconexion en ms (default 1000)"
|
||||
- name: max_reconnect_interval
|
||||
desc: "Delay maximo de reconexion en ms (default 30000)"
|
||||
- name: max_messages
|
||||
desc: "Numero maximo de mensajes a mantener en buffer (default 100)"
|
||||
- name: on_message
|
||||
desc: "Callback por cada mensaje recibido con el dato parseado"
|
||||
output: "Objeto con send, last_message, messages[], status (connecting/open/closing/closed), close() y connect()"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/core/use_websocket.tsx"
|
||||
props:
|
||||
- name: url
|
||||
type: "string"
|
||||
required: true
|
||||
description: "URL del WebSocket"
|
||||
- name: enabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Conectar al montar (default true)"
|
||||
- name: reconnect
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Auto-reconectar (default true)"
|
||||
- name: reconnect_interval
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Delay inicial reconexion en ms (default 1000)"
|
||||
- name: max_reconnect_interval
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Delay maximo reconexion en ms (default 30000)"
|
||||
- name: max_messages
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Tamano del buffer de mensajes (default 100)"
|
||||
emits: [on_message, on_open, on_close, on_error]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useWebSocket } from '@/functions/core/use_websocket'
|
||||
|
||||
interface ChatMessage { user: string; text: string; ts: number }
|
||||
interface SendMessage { text: string }
|
||||
|
||||
function Chat() {
|
||||
const { send, messages, status } = useWebSocket<SendMessage, ChatMessage>({
|
||||
url: 'ws://localhost:8484/ws/chat',
|
||||
reconnect: true,
|
||||
max_messages: 50,
|
||||
})
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Status: {status}</div>
|
||||
<div style={{ height: 300, overflow: 'auto' }}>
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i}><b>{msg.user}:</b> {msg.text}</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={input}
|
||||
disabled={status !== 'open'}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && input.trim()) {
|
||||
send({ text: input })
|
||||
setInput('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Genericos separados: `TSend` para envio, `TRecv` para recepcion (pueden diferir)
|
||||
- Mensajes enviados antes de `open` se bufferean y envian al conectar
|
||||
- El buffer `messages` se limita a `max_messages` (FIFO, elimina el mas viejo)
|
||||
- El backoff exponencial duplica el delay hasta `max_reconnect_interval`
|
||||
- `close()` previene reconexion automatica; `connect()` fuerza reconexion manual
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type WebSocketStatus = 'connecting' | 'open' | 'closing' | 'closed'
|
||||
|
||||
export interface UseWebSocketOptions<TRecv> {
|
||||
/** URL del WebSocket (ej: ws://localhost:8484/ws/chat) */
|
||||
url: string
|
||||
/** Si conecta automaticamente al montar (default true) */
|
||||
enabled?: boolean
|
||||
/** Si reconecta automaticamente al perder la conexion (default true) */
|
||||
reconnect?: boolean
|
||||
/** Delay inicial de reconexion en ms (default 1000) */
|
||||
reconnect_interval?: number
|
||||
/** Delay maximo de reconexion en ms (default 30000) */
|
||||
max_reconnect_interval?: number
|
||||
/** Numero maximo de mensajes a mantener en el buffer (default 100) */
|
||||
max_messages?: number
|
||||
/** Callback por cada mensaje recibido */
|
||||
on_message?: (data: TRecv) => void
|
||||
/** Callback al conectar */
|
||||
on_open?: () => void
|
||||
/** Callback al cerrar */
|
||||
on_close?: (event: CloseEvent) => void
|
||||
/** Callback al error */
|
||||
on_error?: (error: Event) => void
|
||||
}
|
||||
|
||||
export interface UseWebSocketResult<TSend, TRecv> {
|
||||
/** Enviar un mensaje (serializado como JSON) */
|
||||
send: (data: TSend) => void
|
||||
/** Ultimo mensaje recibido */
|
||||
last_message: TRecv | null
|
||||
/** Buffer de mensajes recibidos (limitado a max_messages) */
|
||||
messages: TRecv[]
|
||||
/** Estado de la conexion */
|
||||
status: WebSocketStatus
|
||||
/** Cerrar la conexion manualmente */
|
||||
close: () => void
|
||||
/** Reconectar manualmente */
|
||||
connect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook WebSocket con auto-reconexion y backoff exponencial.
|
||||
* Serializa mensajes enviados como JSON. Parsea mensajes recibidos como JSON.
|
||||
* Mantiene buffer de los ultimos N mensajes.
|
||||
*/
|
||||
export function useWebSocket<TSend, TRecv>(
|
||||
opts: UseWebSocketOptions<TRecv>,
|
||||
): UseWebSocketResult<TSend, TRecv> {
|
||||
const {
|
||||
url,
|
||||
enabled = true,
|
||||
reconnect = true,
|
||||
reconnect_interval = 1000,
|
||||
max_reconnect_interval = 30_000,
|
||||
max_messages = 100,
|
||||
on_message,
|
||||
on_open,
|
||||
on_close,
|
||||
on_error,
|
||||
} = opts
|
||||
|
||||
const [messages, setMessages] = useState<TRecv[]>([])
|
||||
const [lastMessage, setLastMessage] = useState<TRecv | null>(null)
|
||||
const [status, setStatus] = useState<WebSocketStatus>('closed')
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const currentIntervalRef = useRef(reconnect_interval)
|
||||
const mountedRef = useRef(true)
|
||||
const closedManuallyRef = useRef(false)
|
||||
const pendingMessages = useRef<TSend[]>([])
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
closedManuallyRef.current = false
|
||||
currentIntervalRef.current = reconnect_interval
|
||||
|
||||
setStatus('connecting')
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return
|
||||
setStatus('open')
|
||||
currentIntervalRef.current = reconnect_interval
|
||||
on_open?.()
|
||||
|
||||
// Enviar mensajes pendientes
|
||||
for (const msg of pendingMessages.current) {
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
pendingMessages.current = []
|
||||
}
|
||||
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
let parsed: TRecv
|
||||
try {
|
||||
parsed = JSON.parse(e.data as string) as TRecv
|
||||
} catch {
|
||||
parsed = e.data as unknown as TRecv
|
||||
}
|
||||
|
||||
setLastMessage(parsed)
|
||||
setMessages((prev: TRecv[]) => {
|
||||
const next = [...prev, parsed]
|
||||
return next.length > max_messages ? next.slice(next.length - max_messages) : next
|
||||
})
|
||||
on_message?.(parsed)
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (!mountedRef.current) return
|
||||
setStatus('closed')
|
||||
wsRef.current = null
|
||||
on_close?.(e)
|
||||
|
||||
if (reconnect && !closedManuallyRef.current) {
|
||||
const delay = Math.min(currentIntervalRef.current, max_reconnect_interval)
|
||||
currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval)
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current && !closedManuallyRef.current) {
|
||||
connect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
if (!mountedRef.current) return
|
||||
on_error?.(e)
|
||||
}
|
||||
}, [url, reconnect, reconnect_interval, max_reconnect_interval, max_messages, on_message, on_open, on_close, on_error])
|
||||
|
||||
const close = useCallback(() => {
|
||||
closedManuallyRef.current = true
|
||||
clearReconnectTimer()
|
||||
if (wsRef.current) {
|
||||
setStatus('closing')
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
setStatus('closed')
|
||||
}, [])
|
||||
|
||||
const send = useCallback((data: TSend) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data))
|
||||
} else {
|
||||
// Buffer el mensaje para enviarlo cuando conecte
|
||||
pendingMessages.current.push(data)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (enabled) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearReconnectTimer()
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
send,
|
||||
last_message: lastMessage,
|
||||
messages,
|
||||
status,
|
||||
close,
|
||||
connect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: APIClientConfig
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface APIClientConfig {
|
||||
base_url: string
|
||||
headers?: Record<string, string>
|
||||
on_error?: (error: Error, url: string, init?: RequestInit) => void
|
||||
on_unauthorized?: () => void
|
||||
}
|
||||
description: "Configuracion del cliente HTTP. Define base_url, headers fijos, callback de error global y callback de 401 Unauthorized."
|
||||
tags: [api, client, config, http, fetch, auth]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/core/api_client_config.ts"
|
||||
---
|
||||
|
||||
## Campos
|
||||
|
||||
- `base_url` — prefijo de URL para todos los requests, sin slash final (ej: `"http://localhost:8484/api"`)
|
||||
- `headers` — headers HTTP fijos que se mergeaan en cada peticion (ej: `{ Authorization: 'Bearer ...' }`)
|
||||
- `on_error` — callback global llamado cuando cualquier peticion falla, recibe el error, la URL y el RequestInit
|
||||
- `on_unauthorized` — callback llamado cuando el servidor responde 401, tipicamente redirige al login
|
||||
|
||||
## Uso
|
||||
|
||||
```typescript
|
||||
import type { APIClientConfig } from '@/types/core/api_client_config'
|
||||
import { apiClient } from '@/functions/core/api_client'
|
||||
|
||||
const config: APIClientConfig = {
|
||||
base_url: 'http://localhost:8484/api',
|
||||
headers: { 'X-Client': 'dashboard' },
|
||||
on_unauthorized: () => (window.location.href = '/login'),
|
||||
}
|
||||
const api = apiClient(config)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pasado como primer argumento a `api_client_ts_core`. Los interceptors `on_error` y `on_unauthorized` son globales — aplican a todos los metodos (get, post, put, del, patch) del cliente creado.
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Configuracion del cliente HTTP creado por api_client. */
|
||||
export interface APIClientConfig {
|
||||
/** URL base para todas las peticiones (ej: "http://localhost:8484/api"). Sin slash final. */
|
||||
base_url: string
|
||||
/** Headers fijos que se incluyen en todas las peticiones */
|
||||
headers?: Record<string, string>
|
||||
/** Callback global de error — llamado en cualquier peticion fallida */
|
||||
on_error?: (error: Error, url: string, init?: RequestInit) => void
|
||||
/** Callback llamado cuando el servidor responde 401 Unauthorized */
|
||||
on_unauthorized?: () => void
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: FetchState
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface FetchState<T> {
|
||||
data: T | null
|
||||
error: Error | null
|
||||
loading: boolean
|
||||
refetch: () => Promise<T>
|
||||
is_stale: boolean
|
||||
}
|
||||
description: "Estado de un fetch GET con cache stale-while-revalidate. Incluye datos, error, loading, refetch y flag de staleness."
|
||||
tags: [fetch, state, http, cache, swr, hook]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/core/fetch_state.ts"
|
||||
---
|
||||
|
||||
## Campos
|
||||
|
||||
- `data` — datos obtenidos, null hasta que llega la primera respuesta
|
||||
- `error` — error de red o de parsing, null si no hubo error
|
||||
- `loading` — true mientras hay una peticion en vuelo
|
||||
- `refetch` — funcion para re-ejecutar la peticion manualmente
|
||||
- `is_stale` — true cuando se muestran datos del cache mientras se refetchea en background
|
||||
|
||||
## Uso
|
||||
|
||||
```typescript
|
||||
import type { FetchState } from '@/types/core/fetch_state'
|
||||
|
||||
function useMyHook(): FetchState<User[]> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Retornado por `use_fetch_ts_core`. El patron stale-while-revalidate permite mostrar datos viejos inmediatamente mientras se actualizan en background, mejorando la UX percibida.
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Estado de un fetch GET con cache stale-while-revalidate. */
|
||||
export interface FetchState<T> {
|
||||
/** Datos obtenidos, null si todavia no se han cargado */
|
||||
data: T | null
|
||||
/** Error si la peticion fallo */
|
||||
error: Error | null
|
||||
/** Si la peticion esta en progreso */
|
||||
loading: boolean
|
||||
/** Re-ejecutar la peticion manualmente */
|
||||
refetch: () => Promise<T>
|
||||
/** Si los datos son stale (cache viejo mostrando mientras refetch en background) */
|
||||
is_stale: boolean
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: FormState
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface FormState<T extends Record<string, unknown>> {
|
||||
values: T
|
||||
errors: Partial<Record<keyof T, string>>
|
||||
touched: Partial<Record<keyof T, boolean>>
|
||||
is_valid: boolean
|
||||
is_submitting: boolean
|
||||
}
|
||||
description: "Estado de un formulario con validacion sincrona, tracking de touched y flag de submit en progreso."
|
||||
tags: [form, state, validation, touched, hook]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/core/form_state.ts"
|
||||
---
|
||||
|
||||
## Campos
|
||||
|
||||
- `values` — valores actuales del formulario, tipados con el generico T
|
||||
- `errors` — errores de validacion por campo (string con mensaje), undefined si el campo es valido
|
||||
- `touched` — true por campo cuando el usuario lo ha modificado (util para mostrar errores solo al tocar)
|
||||
- `is_valid` — true cuando no hay ningun error de validacion en ningun campo
|
||||
- `is_submitting` — true mientras el handler de submit esta en ejecucion
|
||||
|
||||
## Uso
|
||||
|
||||
```typescript
|
||||
import type { FormState } from '@/types/core/form_state'
|
||||
|
||||
interface LoginForm { email: string; password: string }
|
||||
|
||||
function LoginPage() {
|
||||
const form: FormState<LoginForm> = useForm({ initial_values: { email: '', password: '' } })
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Retornado por `use_form_ts_core`. El generico `T extends Record<string, unknown>` permite tipado completo de los campos y sus errores. Para formularios complejos con validacion async o arrays de campos, usar `@mantine/form` directamente.
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Estado de un formulario con validacion sincrona. */
|
||||
export interface FormState<T extends Record<string, unknown>> {
|
||||
/** Valores actuales del formulario */
|
||||
values: T
|
||||
/** Errores por campo (undefined si el campo no tiene error) */
|
||||
errors: Partial<Record<keyof T, string>>
|
||||
/** Campos que el usuario ha modificado al menos una vez */
|
||||
touched: Partial<Record<keyof T, boolean>>
|
||||
/** Si todos los campos pasan la validacion */
|
||||
is_valid: boolean
|
||||
/** Si el submit esta en progreso */
|
||||
is_submitting: boolean
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: MutationState
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
interface MutationState<TData, TVariables> {
|
||||
mutate: (variables: TVariables) => void
|
||||
mutate_async: (variables: TVariables) => Promise<TData>
|
||||
data: TData | null
|
||||
error: Error | null
|
||||
loading: boolean
|
||||
reset: () => void
|
||||
}
|
||||
description: "Estado de una mutacion POST/PUT/DELETE. Expone mutate, mutate_async, data, error, loading y reset."
|
||||
tags: [mutation, state, http, post, put, delete, hook]
|
||||
uses_types: []
|
||||
file_path: "frontend/types/core/mutation_state.ts"
|
||||
---
|
||||
|
||||
## Campos
|
||||
|
||||
- `mutate` — ejecutar la mutacion de forma fire-and-forget (no lanza excepciones)
|
||||
- `mutate_async` — ejecutar la mutacion y obtener una Promise (puede rechazar)
|
||||
- `data` — resultado de la ultima mutacion exitosa, null si aun no hubo exito
|
||||
- `error` — error de la ultima mutacion fallida, null si fue exitosa o no se ha ejecutado
|
||||
- `loading` — true mientras la mutacion esta en vuelo
|
||||
- `reset` — volver al estado inicial (idle, data null, error null)
|
||||
|
||||
## Uso
|
||||
|
||||
```typescript
|
||||
import type { MutationState } from '@/types/core/mutation_state'
|
||||
|
||||
function useCreateUser(): MutationState<User, CreateUserInput> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Retornado por `use_mutation_ts_core`. El patron `mutate` / `mutate_async` permite usar el hook tanto en event handlers (sin async/await) como en funciones que necesitan esperar el resultado.
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Estado de una mutacion POST/PUT/DELETE. */
|
||||
export interface MutationState<TData, TVariables> {
|
||||
/** Ejecutar la mutacion (fire-and-forget) */
|
||||
mutate: (variables: TVariables) => void
|
||||
/** Ejecutar la mutacion y esperar el resultado */
|
||||
mutate_async: (variables: TVariables) => Promise<TData>
|
||||
/** Datos del resultado de la ultima mutacion exitosa */
|
||||
data: TData | null
|
||||
/** Error si la ultima mutacion fallo */
|
||||
error: Error | null
|
||||
/** Si hay una mutacion en progreso */
|
||||
loading: boolean
|
||||
/** Resetear al estado inicial (idle) */
|
||||
reset: () => void
|
||||
}
|
||||
Reference in New Issue
Block a user