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")))
-- A helper a handler calls: it gets a COPY of the map, so it gives the change back.
task with_total(order)
set order["total"] to order["qty"] * order["price"]
give order
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")
test "the other forms of fail, not_found, respond and redirect"
assert_eq(status of fail("bad"), 400)
assert_eq(value of fail("bad"), {"error": "bad", "status": 400})
assert_eq(value of fail(503), {"error": "error", "status": 503})
assert_eq(value of not_found({"hint": "try /v2"}), {"hint": "try /v2"})
assert_eq(status of respond("", "text/plain", 202), 202)
assert_eq(status of redirect("/login"), 301)
assert_eq(status of redirect("/login", 303), 303)
assert_error(() => redirect("/login", 200))
test "a map a handler passes on is a value: the change comes back with give"
let b be {"qty": 2, "price": 5}
let priced be with_total(b)
assert_eq(priced["total"], 10)
assert(not contains(b, "total")) -- the caller's map did not change
test "request.json reads the body like json_decode"
let bom be decode(bytes([239, 187, 191])) -- a byte order mark, as some clients send it
let body be json_decode(bom + "{\"id\": 18446744073709551616, \"v\": -0}")
assert_eq(body["id"], 2 ** 64) -- exact, not a float
assert_eq(text(body["v"]), "0") -- -0 reads as 0
assert_error(() => json_decode("{\"v\": NaN}")) -- a request with it is a 400
test "optional headers: get with a default instead of indexing"
let headers be {"content-type": "application/json"}
assert_eq(get(headers, "x-api-key", nothing), nothing)
assert_eq(get(headers, "content-type", nothing), "application/json")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 (variable depth)
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 if it doesn't match
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 leé las opcionales con get(request.headers, "x-api-key", nothing) (engine v0.6.29+; el tercer argumento es el default que elijas) o protegelas con contains(request.headers, "x-api-key"). query y params también quedan como locales sueltas, por eso params.id funciona directo; sus valores son siempre texto (int(params.id, nothing) convierte "42" en 42 y "abc" en nothing en vez de un 500). 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 (en una carpeta synsema-spill-<azar> de la carpeta temporal del sistema) — se lee con read_body() / read_body_bytes(). El archivo se borra al terminar la request. Un proceso matado o caído deja su carpeta atrás; desde el motor v0.6.46 un serve las barre una vez, la primera vez que derrama un body: sólo carpetas de verdad (no links) sin tocar hace un día y con sólo archivos igual de viejos (en Unix, además, del mismo dueño y con modo 0700). Los archivos temporales de una escritura atómica (<nombre>.<azar>.synsema.tmp) que un corte deja junto a un archivo no se barren; se pueden borrar.
json of request se lee con el mismo parser que json_decode (engine v0.6.30): un entero de cualquier tamaño queda exacto ({"amount": 115792089237316195423570985008687907853269984665640564039457584007913129639935} llega como ese entero, no como un float redondeado), -0 es 0, y un body que empieza con byte order mark se lee en vez de responderse con 400. NaN, Infinity y 1e400 no son JSON: un body con ellos recibe 400 {"error": "malformed JSON body"}.
El request es un valor (engine v0.6.29+). let b be json of request y pasarle b a una task dan una copia: una task que hace set dentro del map que recibió cambia su propia copia, no la tuya. El cambio sale de la task con give — set b to with_total(b) — y synsema check avisa de una task que escribe en un parámetro y nunca lo devuelve. Los tests del ejemplo muestran el patrón.
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) (también fail(msg) → 400 y fail(code) → {"error": "error"}), not_found(msg) (un map en vez del texto es el body tal cual), respond(text, content_type, status?), redirect(url, status?) (default 301; cualquier otro status tiene que ser 3xx) — 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?) (devuelve el valor nuevo), state_merge(key, map), state_cas(key, expected, new) (atómicas, abajo), 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).
El motor v0.6.39+ lo garantiza para toda escritura — un camino (set rows[0].name to …), un append en el lugar, un escalar, desde una task que el handler llama, y las variables propias de un módulo: cada request arranca con los valores que dejó el programa. Hasta v0.6.38 esa escritura podía quedar en el worker que atendió la request (el mismo GET respondía distinto según el worker): nunca dependas de eso.
Lo que da state_get es una copia (las listas y los maps son valores desde engine v0.6.29): después de let cfg be state_get("cfg"), un set cfg["n"] to 99 cambia solo cfg — state_set("cfg", cfg) lo guarda. Un generador con semilla (rng(seed)) creado en el nivel superior no se puede usar dentro de un request (reiniciaría la misma secuencia en cada uno, así que es un error que lo dice): crealo dentro del handler.
Cambiar un valor compartido sin perder escrituras — state_merge, state_cas (engine v0.6.45+)§
state_get + cambio + state_set son tres pasos, y dos requests al mismo tiempo pueden leer las dos el valor viejo: el segundo state_set borra el cambio del primero (13 webhooks simultáneos que agregaban cada uno una clave a un mismo map dejaron 7). Dos operaciones atómicas hacen la lectura y la escritura en un solo paso:
state_merge(key, map)mezclamapen el map guardado bajokey(superficial: sus claves
reemplazan o agregan, el resto queda) y da el resultado. Una clave que falta arranca de {}. Si el valor guardado no es un map es un error: state_merge: the value of 'x' is number, not a map — use state_set to replace it.
state_cas(key, expected, new)escribenewsolo si el valor actual es igual aexpected, y
da true si escribió. Una clave que falta es nothing; la igualdad es la del lenguaje (2.0 es igual a 2). Reintentá o desistí cuando da false.
state_incr(key, delta?)era y sigue siendo el contador atómico.
require serve(8080)
serve on 8080
-- each webhook adds its own key to one shared map, atomically: none is lost
route "POST /hook/:id"
give state_merge("seen", {params.id: true})
-- claim a job once: of many requests at the same time, exactly one gets won = true
route "POST /claim/:job"
let key be "job:" + params.job
state_cas(key, nothing, "free") -- creates it only if it is missing
give {"won": state_cas(key, "free", "taken")}
Los 13 webhooks ahora dejan 13 claves, y de ocho POST /claim/j1 simultáneos exactamente uno responde {"won": true}. En el build wasm existen también; con el kv del host son atómicas solo si el host ofrece kv.cas (ver WASM).
Rendimiento y memoria§
- Dónde corre el cuerpo de una ruta. Desde el motor v0.6.39, el cuerpo de las rutas corre en
la VM de bytecode y sus bucles calientes en el nivel nativo, como synsema run: un bucle de un millón de vueltas escrito en un handler pasó de ~0,3 s a ~5 ms por request (leer una global del programa adentro sigue siendo nativo). Con --labels o serve --attested corren en el intérprete de referencia, como cualquier programa. Hasta v0.6.38 el cuerpo de la ruta corría en el intérprete de referencia: poné un bucle pesado en una task que la ruta llame (llega a código nativo desde su segunda llamada).
- Memoria por worker. Desde el motor v0.6.40 las globales del programa (y el código de los
módulos) se congelan una vez, cuando el programa terminó de armarlas, y todos los workers (SYNSEMA_SERVE_WORKERS, por defecto uno por core) leen esa misma copia; una escritura dentro de un handler copia antes. La RAM es más o menos una copia de los datos más unos pocos MB por worker. Medido: un millón de registros más un millón de enteros en globales, 176 MB con 1 worker y 225 MB con 12. Elegí la cantidad de workers por CPU. Hasta v0.6.39 cada worker armaba su propia copia en su primera request (160–190 MB por worker para un millón de registros chicos, unos 2,6 GB con 12): ahí, mantené pocos workers con globales grandes.
- Esperar no ocupa un worker. Desde el motor v0.6.42,
SYNSEMA_SERVE_WORKERSlimita el
trabajo de CPU, no las requests abiertas. Un handler que espera — sleep, wait_for, select, una llamada HTTP o a un LLM, una base de datos, una aprobación, un proceso hijo, tcp_/pipe_ — suelta su permiso de CPU mientras espera, y el pool suma hilos (hasta SYNSEMA_SERVE_MAX_WAITING, 256 por defecto) para que las demás requests sigan. Medido con 2 permisos: seis rutas que duermen 2 s terminan juntas en unos 2,7 s y una ruta rápida responde mientras tanto (antes: de a dos, y la rápida esperaba detrás). El código sigue siendo lineal — no hay async. Los hilos extra se van apenas se vacía la cola. Con un presupuesto de tokens de LLM (SYNSEMA_LLM_BUDGET), las llamadas en vuelo se limitan a SYNSEMA_SERVE_WORKERS, así el presupuesto no se pasa.
Proxy, túneles y la IP del cliente (motor v0.6.42+)§
proxy to es una sentencia, como give: una ruta puede chequear algo antes, responder ella misma o elegir el destino en cada request. A un destino calculado por request se le chequea el permiso net en cada request, sobre el mismo host al que se conecta el server (sólo host:puerto — se rechazan @, ?, #, \, % y los espacios). Un literal, una constante o env(...) se chequea una vez al arrancar.
Un net con comodín no llega a un upstream privado (motor v0.6.46+). require net("*") (o un require net pelado) llega sólo a direcciones públicas, y eso se chequea en cada conexión que abre proxy to: con un comodín y un upstream en 127.0.0.1, 10.x, 192.168.x… el server arranca, pero cada request recibe 502 {"error": "proxy error: upstream not allowed", "status": 502} y el motivo, con la dirección, va al log del server (`[serve] proxy error: proxy to: 127.0.0.1 resolved to 127.0.0.1, a private address; a net grant with a wildcard reaches only public addresses. Name it: require net("127.0.0.1:9000")…`). Nombrá el upstream — su host, su dirección o su CIDR — como abajo. Los rangos están en Cliente HTTP.
require serve(8080)
require net("127.0.0.1:9000")
require net("127.0.0.1:9001")
task backend_for(r)
when get(r["query"], "v") == "2"
give "http://127.0.0.1:9001"
give "http://127.0.0.1:9000"
serve on 8080
route "GET /api/*path"
when get(headers of request, "x-token") != "ok"
give fail(401, "unauthorized") -- answered here; nothing is forwarded
proxy to backend_for(request) -- no `net` grant for it → an error, never a connection
El body de la request viaja al destino sin leerse antes (max_body igual cuenta los bytes). Si la ruta lee el body ella misma — read_body(), expect, body/form/json of request — se lee entero y se reenvía byte a byte. Leído de otra forma (en una task que llama la ruta, get(request, "body")) el body en streaming no está, así que chequeá un body en la ruta. Una route "OPTIONS …" declarada le gana a la respuesta automática, así un preflight de CORS puede llegar al servicio de atrás. proxy to también puede apuntar a un extremo de pipe — un túnel escrito en Synsema, en Apps agénticas.
Detrás de nginx, Caddy o un balanceador, ponelo en trust proxy. X-Forwarded-For, -Proto y -Host se conservan sólo cuando el par TCP está en la lista; de cualquier otro se quitan (junto con Forwarded) antes de que los vean el programa, ip of request, el rate limit, las URLs generadas o un proxy to — cualquier cliente puede mandarlos.
require serve(8080)
serve on 8080
trust proxy ["127.0.0.1", "10.0.0.0/8"] -- your proxies (or --trust-proxy on the command line)
route "GET /whoami"
give {"ip": ip of request} -- the real client, not the proxy
El proxy de confianza tiene que poner X-Forwarded-Proto y -Host (no pasar los del cliente). Sin trust proxy, detrás de un proxy todas las requests parecen venir del proxy — un solo balde de rate limit para todos. Una request con más de un header Host es un 400.
Incluido§
- Paginación:
give paged("SELECT … ORDER BY id")— pushdown deLIMIT/OFFSET+COUNT(*)exacto. - SSE: un bloque
streamconsendpara server-sent events (también dentro de un grupoexport routesmontado, engine v0.6.20+) — 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) — necesitarequire file.read("./static/")(engine v0.6.45+: servir una carpeta es leerla, y el archivo lo elige un cliente remoto; sin eso el servidor no arranca, y el error nombra la línea que falta; una carpeta que va en el bundle desynsema buildes parte del programa, no necesita nada y se sirve solo desde el bundle), 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). Elrequestque recibe es el mismo map que ve una ruta —path of request,query of request(un map; la query string no está dentro depath),method,headers,cookies,ip— así un redirect de "entrá y volvé" conserva sus parámetros (verificado, engine v0.6.19). - 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/socketdentro de un grupo funcionan desde engine v0.6.20 (antes se rechazaban); una ruta, o el grupo entero, puede serprivate(más abajo). 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, o--tls-cert/--tls-keypara tus propios archivos) o en el bloque:tls auto, otls cert "./certs/cert.pem" key "./certs/key.pem"— esa cláusula lee los dos archivos, así que necesitarequire file.readsobre cada uno (engine v0.6.45+; los flags son del operador y no necesitan nada) — un certificado por nombre, comodines contls dns, los dominios propios de tus clientes condomain ask(motor v0.6.42+; ver Deploy); 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); las plantillas derender("literal.html")(y susinclude/layout) se parsean al arrancar, y una rota frena el arranque (template validation failed for render("…"): …). Desde el motor v0.6.46 ese chequeo lee sólo lo que la ejecución puede leer: una plantilla cuyorequire file.readestá dentro de la ruta no se abre al arrancar — stderr dicenote: render("templates/page.html"): "templates/page.html" is not checked at startup (Capability not granted: …); render() checks it when the route runs— yrender()la chequea cuando corre la ruta, como antes. Dalo al principio del archivo para que se chequee al arrancar. Una plantilla dentro del bundle desynsema buildno necesita permiso;synsema checkysynsema buildsiguen leyendo todas las plantillas, como herramientas del proyecto. - Observabilidad:
log/printllegan a la terminal con prefijo[serve](bajoservenada cambió en v0.6.42; bajosynsema run,logahora va a stderr — CLI).
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 | |
/.well-known/agent-card.json | La Agent Card del servidor (forma A2A: un skill por ruta, esquemas de auth, el did:key del servidor), firmada con SYNSEMA_IDENTITY_KEY o con la clave atestada — Identidad de agentes | alias /.well-known/agent.json; supportedInterfaces es [] a propósito (acá no hay transporte A2A) |
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" -- the title: /llms.txt, OpenAPI info.title, /docs
api: ["GET /books/:id -- one book", "POST /orders -- place an order"]
version: "1.4.0" -- OpenAPI info.version (default "0.0.0")
-- private -- internal server: /llms.txt, /openapi.json, /sitemap.xml, /docs → 404; robots Disallow: /
-- docs off -- no /docs page; /openapi.json stays published
-- discovery off -- none of them: every reserved URL reaches your routes (an edge)
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.discovery off(motor v0.6.46+) es para un edge que sólo reenvía conproxy to: el server no genera ninguno de/llms.txt,/openapi.json,/sitemap.xml,/robots.txt,/docs, las Agent Cards ni/.well-known/synsema-auth, y esas URLs llegan a las rutas del programa como cualquier otra — unGET /pathlas reenvía al backend; sin ruta son404. Antes, un edge tenía que declarar cada URL reservada como ruta literal, y cada nueva que agregaba el motor publicaba la página propia del edge en silencio. Condiscovery offel motor no responde nada,privateincluido (/robots.txttambién es 404). Es una cláusula del bloque serve (discoveryseguido de otra cosa es el error de parseoExpected 'off' after 'discovery' (discovery off))./.well-known/attestationes identidad, no descubrimiento: bajo--attestedse sigue sirviendo y una ruta sigue sin poder tomarla, y ahí una ruta literal sobre cualquier URL reservada sigue siendo un error de carga (serve --attested: routeGET /llms.txtcollides with a reserved path …; unGET /pathsí las recibe).synsema openapisigue describiendo las rutas — es una herramienta offline que pedís vos.
require serve(8080)
require net("127.0.0.1:9000")
serve on 8080
discovery off -- this edge publishes nothing of its own
route "GET /*path"
proxy to "http://127.0.0.1:9000" -- /llms.txt, /openapi.json, /docs… are the backend's
privateen una ruta o en un grupo (engine v0.6.20+): la líneaprivateal principio del cuerpo de una ruta, o al principio de un grupoexport routes, deja la ruta servida pero fuera de/llms.txt,/openapi.json,/sitemap.xml,/docsysynsema openapi— la superficie pública es el mismo documento en todos lados.- Una
routeliteral declarada o un archivo estático en cualquiera de esos paths gana sobre el generado. Una ruta con parámetro no:GET /:langya no se traga/openapi.json(engine v0.6.20+ — las URLs reservadas se sirven primero; un archivo estático en el path exacto sigue ganando), ysynsema checkavisa sobre una ruta así para que nadie se sorprenda. describe,private,docsydiscoveryson 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.
El documento desde adentro del programa — openapi_json() (engine v0.6.20+). Bajo serve devuelve el documento OpenAPI que este server publicaría, ya filtrado, como texto — para publicarlo desde una ruta tuya (otro path, otro filtro, un puerto interno): give respond(openapi_json(), "application/json"). Bajo run es un error (only available under serve). Con varios bloques serve on en un proceso, cada ruta recibe el documento de su propio server.
Un endpoint de salud, si el host lo pide (engine v0.6.20+). El runtime no inventa ningún /health — las rutas las declara el programa. Un operador (Kubernetes, un balanceador) puede activarlo con SYNSEMA_HEALTH_PATH=/healthz en el entorno del proceso o con synsema serve --health /healthz: GET en ese path responde 200 {"ok":true,"uptime_s":n,"in_flight":n,"engine":"<versión>"}, sin auth ni rate limit, y nunca aparece en el descubrimiento. Si el programa declara una ruta GET en el mismo path, gana la ruta y el server avisa por stderr al arrancar. synsema build --serve no lo hornea: la variable se fija donde corre el binario.
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.