---
slug: 40-serve
title: Servidor HTTP
description: El servidor HTTP de producción integrado de Synsema — rutas, params de ruta, auth y validación declarativas, paginación, SSE, rate limiting, archivos estáticos, CORS y HTTPS automático.
example_ids: [serve]
---

# Servidor HTTP

Un servidor HTTP de producción nativo — sin framework que agregar (async `hyper`/`tokio`). Todo es deny-by-default, así que un servidor necesita `require serve(port)`.

```synsema
-- Doc example: the serve response contract. Helpers return {status, value}; the
-- runtime renders them. (A real `serve on` block doesn't terminate, so the doctest
-- asserts the response shapes the handlers give — see the prose for a full server.)
intent: "doc example: serve response contract"

print("ok → " + text(status of ok({"a": 1})) + ",  fail(400) → " + text(status of fail(400, "bad")))

test "uniform response helpers carry a status + value"
    assert_eq(status of ok({"a": 1}), 200)
    assert_eq(status of created({"id": 1}), 201)
    assert_eq(status of fail(400, "bad input"), 400)
    assert_eq(status of not_found("missing"), 404)
    assert_eq((value of fail(400, "bad input"))["error"], "bad input")
```

## Rutas y params

```synsema
require serve(8080)

serve on 8080
    route "GET /products"
        give sql("SELECT id, name, price FROM products")
    route "GET /products/:id"
        give sql("SELECT * FROM products WHERE id = ?", [params.id])
    route "GET /files/*path"            -- catch-all (profundidad variable)
        give read_file(params.path)
```

Las rutas matchean por **especificidad** (exacto > `:param` > `*catchall`), no por orden de declaración.

## Auth y validación

```synsema
serve on 8080
    auth with check_token
    route "POST /products" requires auth
        expect body {name: text, price: number}    -- 400 si no coincide
        give created(json of request)
```

El auth task recibe el bearer token; declaralo con **2 parámetros** (`task check(token, request)`) y recibe también el map del request — eso es lo que destraba las sesiones por cookie ([Login y sesiones](/es/0.6.x/40a-web-auth)).

## El request y las respuestas

`request` tiene `.method .path .body .json .form .headers .cookies .query .params .user .ip .body_file` (`.cookies` — engine v0.5.5+; `.form` — engine > v0.5.9). **`form of request`** es el body de formulario parseado: urlencoded → `{campo: texto}`; multipart → campos de texto más archivos subidos como `{filename, content_type, data}` (bytes exactos); sin form body → map vacío — un `<form method="post">` clásico no necesita fetch ni JSON. `.headers`, `.query` y `.params` son **maps** — se indexan por clave (`request.headers["authorization"]`; los nombres de header van en minúscula). Indexar una clave **ausente** es error, así que protegé las opcionales con `contains(request.headers, "x")`. `query` y `params` también quedan como locales sueltas, por eso `params.id` funciona directo. Cuando un body grande se derrama a disco (más de ~1 MiB), `.body` queda vacío y `.body_file` es la ruta a un archivo temporal — se lee con `read_body()` / `read_body_bytes()`.

**Alcance del handler:** `request`, `query` y `params` viven solo en el scope del propio handler — una task que el handler llama **no** las ve. Pasale a la task lo que necesita como argumento (p. ej. `lookup_user(request)`), nunca un `request` pelado referenciado dentro de esa task.

Las respuestas usan los helpers uniformes — `ok(x)`, `created(x)`, `fail(code, msg)`, `not_found(msg)`, `respond(text, content_type)`, `redirect(url)` — o `give` de un valor directo. Cualquiera puede llevar headers o cookies extra: `with_header(resp, name, value)` / `set_cookie(resp, name, value, opts?)` — ver **[Login y sesiones](/es/0.6.x/40a-web-auth)**.

## Estado compartido entre requests (`state_*`)

Un `set` sobre un global dentro de un handler **no** persiste al request siguiente — cada
request corre sobre su propio snapshot de los globales. El estado compartido entre
requests/handlers vive en un store en memoria con la vida del server: `state_set(key, value)`,
`state_get(key, default?)`, `state_incr(key, delta?)`, `state_delete(key)`, `state_all()` (un
mapa-snapshot de todas las claves — `{"a": 1, "hits": 2}`). Se pierde al reiniciar; para
estado durable, una base de datos o la memoria declarada (**[Memoria y estado](/es/0.6.x/61-memory)**).

## Incluido

- **Paginación:** `give paged("SELECT … ORDER BY id")` — pushdown de `LIMIT`/`OFFSET` + `COUNT(*)` exacto.
- **SSE:** un bloque `stream` con `send` para server-sent events — un LLM puede streamear ahí token a token con [`llm_stream`](/es/0.6.x/50-llm-primitives). El server escribe un comentario `: keepalive` tras 15 s sin frames (`SYNSEMA_SSE_KEEPALIVE`), así los proxies no cortan streams silenciosos (engine v0.6.7+). Alimentalo desde un agente/cron/otro request por el bus de eventos (`bus_subscribe` + `bus_recv`) — ver [Apps agénticas](47-agentic-apps).
- **Rutas WebSocket (engine v0.6.7+):** un bloque `socket` en vez de `stream` acepta un WebSocket entrante; `socket` es el handle de la conexión y toda la familia `ws_*` funciona sobre él. `426` sin upgrade, auth antes del upgrade, códigos de cierre honestos — [Apps agénticas](47-agentic-apps).
- **Timeouts y cancelación (engine v0.6.7+):** `timeout N` en el bloque serve y/o en una ruta (`timeout none` la exime). Sin cláusula = sin límite. Al vencer: `504` y el handler se cancela de verdad (cooperativo, chequeado por statement y en cada espera). `Ctrl-C`/SIGTERM drena lo que está en vuelo (`SYNSEMA_SHUTDOWN_GRACE`, default 10 s) y sale con 0 — [Apps agénticas](47-agentic-apps).
- **Rate limiting:** `rate_limit N per window`.
- **Archivos estáticos:** `static "/assets" from "./static"` (ETag/Range/gzip), más **`cache "1h"`** (Cache-Control por mount; `"immutable"`, `"no-store"`, `<N>s/m/h/d`) y **`fallback "index.html"`** (history-fallback de SPA). Las rutas declaradas ganan sobre static — desde una ruta declarada, serví un asset **binario** con `binary(read_file_bytes(p), "image/png")` (`read_file` a secas es lossy en UTF-8). Más en [Frontend](41-frontend).
- **Dónde escucha — la cláusula `bind` (engine v0.6.18+):** `bind "127.0.0.1"` dentro del bloque serve hace local al listener (cualquier IP o nombre de host; se evalúa al arrancar). `--bind` en la línea de comandos le sigue ganando; sin ninguno de los dos el default sigue siendo `0.0.0.0`. Dentro de un bloque `host` es un error. `synsema build --serve` hornea el literal de la cláusula. Una app de escritorio es un server con `bind "127.0.0.1"` que llama `shutdown()` cuando se cierra su última ventana — [Tu app en el escritorio](41c-desktop).
- **App instalable / PWA (engine v0.6.15+):** un manifest + un service worker servidos desde `/` (`.webmanifest` sale como `application/manifest+json`) hacen instalable al sitio en Android, iOS y escritorio; push nativo con `push_send` (`require net(<push service>)`) — todo lo genera `synsema init --pwa`. Mirá [Tu app en el teléfono](41b-pwa).
- **Páginas de error propias:** `errors with <task>` — una `task(status, message, request)` da forma a 401/404/405/500 (HTML para navegadores, `nothing` conserva el JSON default para agentes; un `redirect()` se respeta — el patrón "401 → login"; todo otro status se conserva, nada de soft-404).
- **Rutas montadas:** `mount shop.tienda [at "/store"]` monta un grupo `export routes` de un módulo — partí un serve grande en archivos (solo a nivel serve, no dentro de bloques `host`). `rate_limit`/`timeout` por ruta dentro de un grupo funcionan desde engine v0.6.19 (zona propia por ruta montada; un prefijo es otra zona); las rutas `stream`/`socket` se quedan en el bloque serve, y `synsema check` las rechaza en un grupo antes que `serve`. Mirá [Módulos](14-modules).
- **CORS:** `cors "*"`. **Negociación:** `content()` sirve HTML/Markdown/JSON desde una sola fuente.
- **Descubrimiento:** `/llms.txt`, `/robots.txt`, `/sitemap.xml`, `/openapi.json` y `/docs` se generan desde la tabla de rutas — ver [Descubrimiento](#descubrimiento-lo-que-todo-server-publica) abajo.
- **gzip en respuestas dinámicas** (render/html/content/JSON ≥ 1 KB) cuando el cliente lo acepta.
- **TLS / HTTPS automático** vía flags del CLI (`--domain … --tls-auto`); HTTP/2, vhosts, reverse proxy.
- **Loop de dev:** `synsema serve app.syn --watch` reinicia al cambiar el `.syn` (templates/estáticos ya se recargan por request); los paths de `render("literal.html")` se validan al arranque.
- **Observabilidad:** `log`/`print` llegan a la terminal con prefijo `[serve]`.

## Descubrimiento — lo que todo server publica

Un server Synsema se describe solo. Todo lo de abajo se **deriva de lo que realmente está cableado** — la tabla de rutas, `expect`, `requires auth`, `rate_limit`, `describe` y las capacidades que declara el código de las rutas. Nada se escribe dos veces, y lo que no se puede derivar con verdad (el schema de la respuesta) se omite, no se inventa.

| URL | Qué | Notas |
|---|---|---|
| `/llms.txt` | Índice Markdown para agentes: título, intent, cada endpoint **con las capacidades que puede usar** (`- POST /pay  [net:api.stripe.com, llm]`), la lista `describe api:` y una sección `## Machine-readable` que apunta a los documentos de abajo | encendido por defecto |
| `/robots.txt` | `Allow: /` + `Sitemap: <base>/sitemap.xml` — `Disallow: /` con `private` | |
| `/sitemap.xml` | Las páginas que un crawler puede visitar sin contexto: rutas `GET` **sin** params de path, **sin** `requires auth`, ni `stream` ni `proxy` | las rutas paramétricas (`/blog/:slug`) **no** se expanden — el runtime no sabe qué slugs existen. Sin `lastmod` (no hay verdad para darle) |
| `/openapi.json` | OpenAPI 3.1 de la tabla de rutas (mapeo abajo) | determinista: paths ascendentes, métodos en orden GET/POST/PUT/PATCH/DELETE — diffeable, anclable en tests |
| `/docs` | La API navegable: cada operación con su schema, un formulario por parámetro de path y body, y **Try it** (un campo de bearer token que vive en el `sessionStorage` de la pestaña; las cookies viajan solas). Sin CDN, sin scripts de terceros. Con `Accept: text/markdown` vuelve la misma referencia en **Markdown para agentes** | `docs off` apaga solo esta página |
| `/.well-known/synsema-auth` | Cómo autenticarse acá — [Identidad de agentes](46-agent-identity) | |

**Base URL** (los links absolutos de sitemap/robots y el `servers` de OpenAPI): el `domain "…"` del serve block si está declarado; si no, el `Host` del request, con `https` cuando hay TLS o un proxy adelante manda `X-Forwarded-Proto: https`.

**Detrás de un proxy, declará `domain`.** El proxy reescribe `Host` con la authority del backend (el `$proxy_host` por defecto de nginx, y también el `proxy to` propio), así que sin `domain` el sitemap diría `https://127.0.0.1:8090/...`. `X-Forwarded-Host` se ignora a propósito: cualquier cliente lo puede inyectar y un proxy genérico lo deja pasar — eso es host header injection, y envenenaría el sitemap, el `robots.txt` y el `servers` de OpenAPI. Misma postura que con `X-Forwarded-For` para rate limiting: el motor solo confía en lo que declaraste.

Forma y opt-outs:

```synsema
serve on 8080
    describe
        about: "Bookshop API"            -- el título: /llms.txt, info.title de OpenAPI, /docs
        api: ["GET /books/:id -- un libro", "POST /orders -- hacer un pedido"]
        version: "1.4.0"                 -- info.version de OpenAPI (default "0.0.0")
    -- private                           -- server interno: /llms.txt, /openapi.json, /sitemap.xml, /docs → 404; robots Disallow: /
    -- docs off                          -- sin página /docs; /openapi.json sigue publicado
    route "POST /orders" requires auth
        expect body {book: text, qty: number}
        give {"ok": true}
```

- `describe about:` / `api:` / `version:` son lo único que escribís. Una entrada de `api:` cuyo prefijo es exactamente `"POST /orders"` se vuelve la `description` de esa operación; el `intent:` del programa es la descripción de la API.
- **`private`** esconde los cuatro documentos generados (como siempre escondió `/llms.txt`); **`docs off`** esconde solo `/docs`.
- Una `route` declarada o un archivo estático en cualquiera de esos paths **gana** sobre el generado.
- `describe`, `private` y `docs` son soft keywords — especiales solo dentro de un serve block.

Cómo se deriva `/openapi.json`:

| OpenAPI | De |
|---|---|
| `info.title` / `info.description` / `info.version` | `describe about:` → `intent:` → `"Synsema service"` / `intent:` / `describe version:` |
| `servers` | la base URL de arriba (ninguno cuando no se puede saber) |
| `paths./a/{id}` + `parameters` | `route "GET /a/:id"` — `:id` y `*rest` pasan a `{id}` / `{rest}`, `in: path`, requeridos, string |
| `requestBody` | el `expect body {…}` de **nivel superior** de la ruta → un JSON schema con todos los campos requeridos (`text`→string, `number`→number, `bool`→boolean, `list`→array, `map`→object). Un `expect` dentro de un `when` es una rama, no un contrato: no se publica |
| `responses.200` | `application/json`; `text/event-stream` en una ruta `stream`; `text/html` cuando el último `give` es `html()`/`render()`/`page()`; los tres tipos negociados para `content()`; `302` para `redirect()`. Se infiere del código, **sin schema de respuesta** — ese es el límite honesto |
| `400` / `401` / `429` | un `expect` / `requires auth` / un rate limit |
| `security` + `components.securitySchemes` | rutas con `requires auth` cuando el bloque tiene `auth with`: `bearer`, `cookie`, `httpsig` (RFC 9421) — los mismos tres que anuncia `/.well-known/synsema-auth` |
| `operationId` | `get_books_id` (método + segmentos del path) |
| `x-synsema-rate-limit` | `{count, window}` (window en segundos; una ruta hereda el del bloque) o `"unlimited"` |
| `x-synsema-streaming` / `x-synsema-proxy` | rutas `stream` / rutas `proxy to` |
| `x-synsema-capabilities` | **lo que la ruta puede tocar**, estáticamente: las líneas `require` de cada task que el código de la ruta invoca (transitivo, también a través de módulos) más lo que implican los builtins que llama (`fetch` → `net`, `sql` → `db`, `read_file` → `file.read`, `reason`/`decide` → `llm`, `remember` → `memory`, …). Es el contrato, no una traza de lo que corrió — el runtime sigue gateando cada llamada. Siempre presente; `[]` cuando no hay nada |

Los grupos montados (`mount m.api at "/v1"`) se publican con su prefijo. Cada bloque `host "…"` publica su propia tabla bajo su `Host`.

**En CI, sin server:** `synsema openapi app.syn --out openapi.json [--base-url https://api.example]` escribe el mismo documento desde el fuente — solo parse, nada corre, no se abre ningún puerto; exit `2` cuando el archivo no tiene `serve`. Ver [CLI](70-cli).

Ver **[Frontend](41-frontend)** para páginas HTML y **[Construí un sitio web](41a-build-a-website)** para el recorrido completo.
