feat: agregar hooks React HTTP genericos — issue 0017
4 tipos en frontend/types/core/: FetchState, MutationState, FormState, APIClientConfig. 9 funciones en frontend/functions/core/: api_client, http_cache, use_fetch, use_mutation, use_infinite_scroll, use_form, use_debounced_search, use_sse, use_websocket. Zero dependencias externas — solo React + fetch nativo. Cache in-memory con SWR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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