Saltar al contenido

Qué son las Actions: funciones de servidor type-safe

El fin del ritual de escribir endpoints a mano para hablar con el servidor: una action es una función type-safe declarada en src/actions/index.ts con defineAction, invocable desde el cliente como si fuera local, con los tipos intactos de un extremo al otro y un resultado data/error que nunca deja cruzar formas rotas por el cable.

El problema: hablar con el servidor sin contrato

Antes de las actions, comunicar el cliente con el servidor pasaba siempre por un endpoint: un fichero en src/pages/api que exporta una función por método HTTP, recibe un Request crudo y devuelve un Response. Funciona, pero deja toda la carga sobre tus hombros. Inventas una URL —/api/crear-tarea— y la escribes como una cadena en el fetch del cliente; serializas el cuerpo con JSON.stringify; en el servidor lo vuelves a parsear; y decides a mano un código de estado y una forma de respuesta que el cliente tendrá que interpretar.

El problema de fondo no es la verbosidad, sino que el cable rompe los tipos. TypeScript comprueba tu código dentro de un módulo, pero no puede seguir una petición HTTP: para el compilador, lo que sale del fetch es un any o, en el mejor caso, un tipo que tú afirmaste sin que nadie lo verifique. El servidor cree que recibe un número; el cliente envía una cadena; ambos compilan; el fallo aparece en producción. Cada endpoint es dos contratos escritos por separado —el que envía y el que recibe— que la nada garantiza que sigan de acuerdo.

TypeScript
// El ritual antiguo: una URL como cadena y un JSON sin tiposconst res = await fetch('/api/crear-tarea', {  method: 'POST',  body: JSON.stringify({ titulo: 'Comprar pan' }),});const data = await res.json(); // any: el cable borro el tipo

Las actions atacan justo esa fractura. En lugar de dos contratos que hay que mantener sincronizados a mano, hay uno solo, escrito en el servidor, del que Astro deriva automáticamente el lado del cliente. La URL desaparece de tu vista, la serialización se automatiza y —lo esencial— el tipo de lo que devuelve el servidor es el tipo que recibe el cliente, porque es literalmente el mismo, inferido de una única fuente.

defineAction y el objeto server

Todas las actions viven en un único punto de entrada: src/actions/index.ts, que exporta una constante llamada server. Es un objeto donde cada clave es el nombre de una action y cada valor, el resultado de defineAction. Esa función recibe un handler —la lógica de servidor que se ejecutará— y, opcionalmente, un input que valida los argumentos antes de entrar.

TypeScript
// src/actions/index.tsimport { defineAction } from 'astro:actions';import { z } from 'astro:schema';export const server = {  saludar: defineAction({    input: z.object({ nombre: z.string() }),    handler: async (input) => {      return `Hola, ${input.nombre}`;    },  }),};

Tres hechos definen a esta pieza. Primero, el handler corre solo en el servidor: su cuerpo nunca se empaqueta para el navegador, así que dentro puedes leer una base de datos, usar una clave secreta o tocar el sistema de ficheros sin miedo a filtrarlo. Segundo, lo que el handler retorna se convierte, del lado del cliente, en el data de la llamada, con su tipo inferido: aquí, una string. Y tercero, al arrancar el servidor de desarrollo Astro ejecuta astro sync, que genera el objeto actions del cliente y sus tipos a partir de este fichero; por eso el editor conoce saludar sin que importes nada manualmente.

El objeto server admite anidamiento, y eso te deja agrupar actions por dominio. Cuando una clave apunta a otro objeto de actions en vez de a una sola, el cliente refleja esa jerarquía: bajo actions.tareas conviven crear y borrar, ordenadas como carpetas de un mismo archivador. En un proyecto que crece, esa organización evita que un único objeto plano degenere en un cajón de sastre con decenas de nombres sueltos.

TypeScript
export const server = {  tareas: {    crear: defineAction({ /* ... */ }),    borrar: defineAction({ /* ... */ }),  },};// En el cliente: await actions.tareas.crear({ /* ... */ })

Una función, dos mundos

Desde el cliente, la action se llama a través del objeto actions, importado de astro:actions. La invocación parece una llamada local, pero por debajo Astro hace un fetch a un endpoint que él mismo generó —bajo la ruta interna _actions— ejecuta el handler en el servidor y te devuelve el resultado ya deserializado.

ASTRO
<script>  import { actions } from 'astro:actions';  const { data, error } = await actions.saludar({ nombre: 'Ada' });  if (!error) {    console.log(data); // "Hola, Ada", tipado como string  }</script>

Observa la forma del retorno: { data, error }, no un valor pelado. Una action nunca lanza por un fallo esperado; en su lugar devuelve una unión discriminada donde, o bien data trae el resultado y error es undefined, o bien error describe qué salió mal y data es undefined. Esa forma es deliberada y la estudiaremos en su propia lección: obliga a mirar el error antes de tocar el dato. La serialización, además, es más rica que un JSON crudo —Astro usa la librería devalue—, de modo que un Date, un Map, un Set o una URL que retornes en el servidor llegan al cliente como lo que son, no como cadenas.

La misma action se puede invocar desde el servidor con Astro.callAction, útil cuando una página quiere reutilizar la lógica sin pasar por la red. Así, el handler se vuelve una única fuente de verdad que sirve a los dos mundos: el cliente lo alcanza por el cable, el servidor lo llama en el sitio, y ninguno duplica la lógica.

Actions frente a endpoints

Las actions no jubilan a los endpoints; los complementan. Un endpoint es una herramienta de bajo nivel: te entrega el Request y espera un Response, y con ese control puedes construir una API REST pública, atender un webhook de terceros o servir un fichero con las cabeceras exactas que quieras. Una action es de más alto nivel: renuncia a ese control a cambio de tipos, validación y serialización automáticas para el caso más común, que es tu propio cliente hablando con tu propio servidor.

La frontera entre ambos no es rígida: nada te impide tener las dos cosas en el mismo proyecto, un puñado de endpoints para lo que el mundo exterior consume y un repertorio de actions para lo que tu propia interfaz necesita. Elegir bien es preguntarse quién llama: si es un tercero que espera una URL estable y un contrato HTTP explícito, un endpoint; si eres tú desde tu cliente y lo que quieres son tipos y menos ceremonia, una action.

Hay un requisito que conviene tener presente desde el principio: como el handler se ejecuta en cada petición, las actions necesitan renderizado bajo demanda, es decir, un adaptador de servidor configurado. Un sitio por lo demás estático puede usar actions, pero su ruta interna se sirve en el servidor. Elegir una action es, por tanto, aceptar que hay un servidor vivo detrás; a cambio recibes un puente cliente-servidor sin costuras.