Servidor HTTP
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).
-- 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§
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§
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).
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.
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).
Incluido§
- Paginación:
give paged("SELECT … ORDER BY id")— pushdown deLIMIT/OFFSET+COUNT(*)exacto. - SSE: un bloque
streamconsendpara server-sent events — un LLM puede streamear ahí token a token conllm_stream. El server escribe un comentario: keepalivetras 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. - Rutas WebSocket (engine v0.6.7+): un bloque
socketen vez destreamacepta un WebSocket entrante;socketes el handle de la conexión y toda la familiaws_*funciona sobre él.426sin upgrade, auth antes del upgrade, códigos de cierre honestos — Apps agénticas. - Timeouts y cancelación (engine v0.6.7+):
timeout Nen el bloque serve y/o en una ruta (timeout nonela exime). Sin cláusula = sin límite. Al vencer:504y 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. - Rate limiting:
rate_limit N per window. - Archivos estáticos:
static "/assets" from "./static"(ETag/Range/gzip), máscache "1h"(Cache-Control por mount;"immutable","no-store",<N>s/m/h/d) yfallback "index.html"(history-fallback de SPA). Las rutas declaradas ganan sobre static — desde una ruta declarada, serví un asset binario conbinary(read_file_bytes(p), "image/png")(read_filea secas es lossy en UTF-8). Más en 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).--binden la línea de comandos le sigue ganando; sin ninguno de los dos el default sigue siendo0.0.0.0. Dentro de un bloquehostes un error.synsema build --servehornea el literal de la cláusula. Una app de escritorio es un server conbind "127.0.0.1"que llamashutdown()cuando se cierra su última ventana — Tu app en el escritorio. - App instalable / PWA (engine v0.6.15+): un manifest + un service worker servidos desde
/(.webmanifestsale comoapplication/manifest+json) hacen instalable al sitio en Android, iOS y escritorio; push nativo conpush_send(require net(<push service>)) — todo lo generasynsema init --pwa. Mirá Tu app en el teléfono. - Páginas de error propias:
errors with <task>— unatask(status, message, request)da forma a 401/404/405/500 (HTML para navegadores,nothingconserva el JSON default para agentes; unredirect()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 grupoexport routesde un módulo — partí un serve grande en archivos (solo a nivel serve, no dentro de bloqueshost).rate_limit/timeoutpor ruta dentro de un grupo funcionan desde engine v0.6.19 (zona propia por ruta montada; un prefijo es otra zona); las rutasstream/socketse quedan en el bloque serve, ysynsema checklas rechaza en un grupo antes queserve. Mirá Módulos. - CORS:
cors "*". Negociación:content()sirve HTML/Markdown/JSON desde una sola fuente. - Descubrimiento:
/llms.txt,/robots.txt,/sitemap.xml,/openapi.jsony/docsse generan desde la tabla de rutas — ver Descubrimiento 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 --watchreinicia al cambiar el.syn(templates/estáticos ya se recargan por request); los paths derender("literal.html")se validan al arranque. - Observabilidad:
log/printllegan 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 |
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:
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 deapi:cuyo prefijo es exactamente"POST /orders"se vuelve ladescriptionde esa operación; elintent:del programa es la descripción de la API.privateesconde los cuatro documentos generados (como siempre escondió/llms.txt);docs offesconde solo/docs.- Una
routedeclarada o un archivo estático en cualquiera de esos paths gana sobre el generado. describe,privateydocsson 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.
Ver Frontend para páginas HTML y Construí un sitio web para el recorrido completo.