Saltar a contenido

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:

// src/lib/tai.ts
import { createTaiClient } from '@/lib/api'

export const api = createTaiClient()
// src/lib/tai.ts
import { createTaiClient } from '$lib/api'

export const api = createTaiClient()
// src/lib/tai.ts
import { createTaiClient } from './api'

export const api = createTaiClient()

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.

// src/routes/+layout.ts
export const ssr = false

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.

import { page } from '$app/state'
import { toStore } from 'svelte/store'

const acceso = accessStore(routes, toStore(() => page.url.pathname + page.url.search + page.url.hash))

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:

await api.session.restore()       // con la cookie, si la hay; si no, queda 'anonymous'

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

const { logout } = useSession()

<button onClick={() => logout()}>Salir</button>
<button onclick={() => $session.logout()}>Salir</button>
await api.session.logout()

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.