Montar la aplicación¶
El paso 2 del protocolo: una instancia del cliente, la sesión en la raíz de la aplicación, la página de login y el botón de salir. Son pocos ficheros tuyos; todo lo demás lo trae el cliente.
1. El cliente¶
Una sola instancia para toda la aplicación, igual en cualquier framework:
createTaiClient() sin argumentos habla con /api, detrás del proxy. No hay URL
de la API en el frontend.
2. La sesión en la raíz¶
Lo que se hace una vez, en lo más alto de la aplicación: poner el cliente al alcance de todos los componentes y recuperar la sesión al cargar —el usuario recarga la página y sigue dentro—. Mientras se recupera, las pantallas protegidas esperan.
// src/App.tsx
import { BrowserRouter, Routes } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { TaiProvider, renderRoutes } from '@/lib/api/react'
import { api } from '@/lib/tai'
import { routes } from '@/routes/routes'
import { AppLayout } from '@/routes/app_layout'
import { Page404, UnderConstruction, FullPageLoader } from '@/components/placeholder'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 0, retry: 1, refetchOnWindowFocus: false } },
})
// Al cerrarse la sesión —logout, caducidad, otro dispositivo—, fuera los datos del usuario.
connectQueryClient(api, queryClient) // de '@/lib/api'
export function App() {
return (
<QueryClientProvider client={queryClient}>
<TaiProvider client={api}>
<BrowserRouter>
<Routes>
{renderRoutes(routes, {
layout: <AppLayout />,
notFound: <Page404 />,
underConstruction: <UnderConstruction />,
loading: <FullPageLoader />,
})}
</Routes>
</BrowserRouter>
</TaiProvider>
</QueryClientProvider>
)
}
| Pieza | Para qué |
|---|---|
QueryClientProvider |
TanStack Query lo necesita: sin él, useQuery no funciona. Ver TanStack Query |
TaiProvider |
Pone api al alcance de toda la aplicación y recupera la sesión al montar |
renderRoutes |
Convierte la declaración de pantallas en las <Route> de react-router, con la protección de cada una. Ver Las pantallas |
layout |
Lo que envuelve a todas las pantallas protegidas: el sidebar, la cabecera… con un <Outlet /> dentro |
loading |
Lo que se ve mientras se comprueba la sesión al cargar |
TaiProvider tiene que estar por encima del router, porque las rutas preguntan por la sesión.
Con SvelteKit, la aplicación es una SPA: la sesión vive en la memoria del navegador y en una cookie que solo ve el navegador, así que no hay nada que pintar en el servidor.
El layout raíz pone el cliente en el contexto —es el TaiProvider de React— y hace de guarda
de todas las pantallas:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'
import { accessStore, setTaiClient } from '$lib/api/svelte'
import { api } from '$lib/tai'
import { routes } from '$lib/routes'
import { Page404, UnderConstruction, FullPageLoader } from '$lib/components/placeholder'
let { children } = $props()
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 0, retry: 1, refetchOnWindowFocus: false } },
})
// Al cerrarse la sesión —logout, caducidad, otro dispositivo—, fuera los datos del usuario.
connectQueryClient(api, queryClient) // de '$lib/api'
setTaiClient(api) // el cliente para toda la aplicación, y la sesión recuperada
const acceso = accessStore(routes, page) // qué hacer en la URL actual
$effect(() => {
if ($acceso.status === 'login') goto($acceso.redirectTo, { replaceState: true })
})
</script>
<QueryClientProvider client={queryClient}>
{#if $acceso.status === 'allowed'}
{@render children()}
{:else if $acceso.status === 'forbidden'}
<Page404 />
{:else if $acceso.status === 'underConstruction'}
<UnderConstruction />
{:else}
<FullPageLoader />
{/if}
</QueryClientProvider>
| Pieza | Para qué |
|---|---|
ssr = false |
La aplicación se pinta en el navegador, donde está la sesión |
QueryClientProvider |
El de @tanstack/svelte-query. Ver TanStack Query |
setTaiClient(api) |
Pone api en el contexto de toda la aplicación y recupera la sesión |
accessStore(routes, page) |
Qué hacer en la URL actual: loading, login, forbidden, underConstruction o allowed. Ver Las pantallas |
goto(redirectTo) |
El adaptador no navega por su cuenta —no sabe qué router hay—: lo hace el layout |
Svelte 4
Lo mismo, con la sintaxis de antes: <slot /> en vez de {@render children()} (y sin
$props()), y $: if ($acceso.status === 'login') goto(...) en vez de $effect. Los stores
del cliente son los de svelte/store, que funcionan igual en las dos versiones.
Con $app/state (SvelteKit 2.12 o posterior)
$app/stores está obsoleto desde SvelteKit 2.12. El page de $app/state no es un store:
se convierte en uno con toStore, leyendo page.url dentro para que siga a la navegación,
y se pasa donde aquí va page. En el login, page.url.searchParams en vez de
$page.url.searchParams. Ver Las pantallas.
El sidebar y la cabecera no van aquí, porque el login no los lleva: van en el layout del grupo de pantallas protegidas (paso 3).
Al arrancar, antes de pintar las pantallas protegidas:
y la sesión se observa con api.session.subscribe. Cómo convertirla en estado de tu framework,
en Otro framework.
3. El layout de las pantallas protegidas¶
El sidebar, la cabecera y el hueco donde va cada pantalla. Sacan sus enlaces y su título de la declaración de pantallas: ver El menú y las migas.
// src/routes/app_layout.tsx
import { Outlet } from 'react-router-dom'
import { Sidebar } from '@/components/shared/sidebar'
import { Header } from '@/components/shared/header'
export function AppLayout() {
return (
<>
<Sidebar />
<Header />
<Outlet />
</>
)
}
Se pasa a renderRoutes como layout: envuelve a las pantallas protegidas, y las públicas —el
login— quedan fuera.
Es buen sitio para el aviso de que la API va sin autorización (tai-api dev):
{useSession().authDisabled && <Banner>Autorización desactivada</Banner>}. Ver
Contra tai-api dev.
Las pantallas protegidas van en un grupo de rutas, (app), con su propio layout; el login, fuera:
src/routes/
├── +layout.ts ssr = false
├── +layout.svelte setTaiClient y la guarda (paso 2)
├── login/+page.svelte pública: sin sidebar
└── (app)/
├── +layout.svelte el sidebar y la cabecera
├── +page.svelte /
└── lotes/… /lotes, /lotes/[id]…
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import Sidebar from '$lib/components/shared/Sidebar.svelte'
import Header from '$lib/components/shared/Header.svelte'
let { children } = $props()
</script>
<Sidebar />
<Header />
{@render children()}
El grupo no cambia las URLs: (app)/lotes es /lotes.
Es buen sitio para el aviso de que la API va sin autorización (tai-api dev):
{#if $session.authDisabled}…{/if}, con const session = getSession(). Ver
Contra tai-api dev.
4. La página de login¶
Cuando una pantalla protegida manda al login, deja dicho de dónde venía. Después de entrar, se vuelve allí.
import { useState } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import { useSession } from '@/lib/api/react'
import { isTaiApiError } from '@/lib/api'
export function LoginPage() {
const { login, isAuthenticated } = useSession()
const navigate = useNavigate()
const desde = (useLocation().state as { from?: Location } | null)?.from?.pathname ?? '/'
const [error, setError] = useState<string | null>(null)
if (isAuthenticated) return <Navigate to={desde} replace />
async function entrar(usuario: string, contraseña: string) {
try {
await login('main', usuario, contraseña) // 'main' es el realm
navigate(desde, { replace: true })
} catch (e) {
setError(isTaiApiError(e) ? e.userMessage ?? 'Usuario o contraseña incorrectos' : 'Sin conexión con el servidor')
}
}
// … el formulario
}
renderRoutes deja la vuelta en location.state.from.
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { getSession, safeReturnPath } from '$lib/api/svelte'
import { isTaiApiError } from '$lib/api'
const session = getSession()
const volver = $derived(safeReturnPath($page.url.searchParams.get('from')))
let usuario = $state('')
let contraseña = $state('')
let error = $state<string | null>(null)
$effect(() => {
if ($session.isAuthenticated) goto(volver, { replaceState: true })
})
async function entrar(evento: SubmitEvent) {
evento.preventDefault()
try {
await $session.login('main', usuario, contraseña) // 'main' es el realm
} catch (e) {
error = isTaiApiError(e) ? e.userMessage ?? 'Usuario o contraseña incorrectos' : 'Sin conexión con el servidor'
}
}
</script>
<form onsubmit={entrar}>…</form>
accessStore deja la vuelta en ?from=, con su búsqueda y su ancla, y safeReturnPath solo la
sigue si es una ruta de este sitio: un enlace a /login?from=https://otro.com (o a
/login?from=/%09/otro.com, que el navegador lee como //otro.com) no saca al usuario de la
aplicación.
import { isTaiApiError } from './api'
import { safeReturnPath } from './api/routes'
try {
await api.session.login('main', usuario, contraseña)
navegar(safeReturnPath(new URLSearchParams(location.search).get('from')))
} catch (e) {
mostrar(isTaiApiError(e) ? e.userMessage : 'Sin conexión con el servidor')
}
Unas credenciales malas son un TaiApiError 401 con su userMessage: no cierran nada.
5. Cerrar la sesión¶
No hace falta navegar al login: al cerrarse la sesión, la guarda manda al login sola. Se cierra en todas las pestañas.
Y las llamadas¶
Desde cualquier componente: await api.public.lote.findMany({ … }), o con TanStack Query. Ver
Llamadas y tipos y TanStack Query.