HTTP server
HTTP server
A native, production HTTP server — no framework to add (async hyper/tokio). Everything is deny-by-default, so a server needs 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")Routes & 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)
Routes match by specificity (exact > :param > *catchall), not declaration order.
Auth & validation§
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)
The auth task receives the bearer token; declare it with 2 parameters (task check(token, request)) and it also receives the request map — that's what unlocks cookie sessions (Login & sessions).
The request & responses§
request has .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 is the parsed form body: urlencoded → {field: text}; multipart → text fields plus file uploads as {filename, content_type, data} (exact bytes); no form body → empty map — classic <form method="post"> posts need no fetch/JSON. .headers, .query and .params are maps — index by key (request.headers["authorization"]; header names are lowercased). Indexing a missing key errors, so guard optional ones with contains(request.headers, "x"). query and params are also bound as bare locals, so params.id works directly. When a large body spills to disk (over ~1 MiB), .body is empty and .body_file is a temp-file path — read it with read_body() / read_body_bytes().
Handler scope: request, query and params live only in the handler's own scope — a task the handler calls does not see them. Pass what the task needs as an argument (e.g. lookup_user(request)), never a bare request referenced inside that task.
Responses use the uniform helpers — ok(x), created(x), fail(code, msg), not_found(msg), respond(text, content_type), redirect(url) — or give a value directly. Any of them can carry extra headers or cookies: with_header(resp, name, value) / set_cookie(resp, name, value, opts?) — see Login & sessions.
Shared state across requests (state_*)§
A set on a global inside a handler does not persist to the next request — each request runs on its own snapshot of the globals. State shared across requests/handlers lives in an in-memory store with the life of the server: state_set(key, value), state_get(key, default?), state_incr(key, delta?), state_delete(key), state_all() (a map snapshot of every key — {"a": 1, "hits": 2}). Gone on restart; for durable state use a database or the declared memory (Memory & state).
Built in§
- Pagination:
give paged("SELECT … ORDER BY id")—LIMIT/OFFSETpushdown + exactCOUNT(*). - SSE: a
streamblock withsendfor server-sent events — an LLM can stream into it token by token withllm_stream. The server writes a: keepalivecomment after 15 idle seconds (SYNSEMA_SSE_KEEPALIVE), so proxies don't cut quiet streams (engine v0.6.7+). Feed it from an agent/cron/another request through the event bus (bus_subscribe+bus_recv) — see Agentic apps. - WebSocket routes (engine v0.6.7+): a
socketblock instead ofstreamaccepts an incoming WebSocket;socketis the connection's handle and the wholews_*family works on it.426without an upgrade, auth before the upgrade, honest close codes — Agentic apps. - Timeouts & cancellation (engine v0.6.7+):
timeout Non the serve block and/or a route (timeout noneopts out). No clause = no limit. At the deadline:504and the handler is really cancelled (cooperative, checked per statement and in every wait).Ctrl-C/SIGTERM drains in-flight work (SYNSEMA_SHUTDOWN_GRACE, default 10 s) and exits 0 — Agentic apps. - Rate limiting:
rate_limit N per window. - Static files:
static "/assets" from "./static"(ETag/Range/gzip), pluscache "1h"(Cache-Control per mount;"immutable","no-store",<N>s/m/h/d) andfallback "index.html"(SPA history-fallback). Declared routes win over static — from a declared route, serve a binary asset withbinary(read_file_bytes(p), "image/png")(plainread_fileis UTF-8-lossy). More in Frontend. - Where it listens — the
bindclause (engine v0.6.18+):bind "127.0.0.1"inside the serve block makes the listener local (any IP or host name; evaluated at start).--bindon the command line still wins; without either the default stays0.0.0.0. Inside ahostblock it is an error.synsema build --servebakes the clause's literal. A desktop app is a server withbind "127.0.0.1"that callsshutdown()when its last window closes — Your app on the desktop. - Installable app / PWA (engine v0.6.15+): a manifest + a service worker served from
/(.webmanifestgetsapplication/manifest+json) make the site installable on Android, iOS and desktop; native push withpush_send(require net(<push service>)) — everything scaffolded bysynsema init --pwa. See Your app on the phone. - Custom error pages:
errors with <task>— atask(status, message, request)shapes 401/404/405/500 (HTML for browsers,nothingkeeps the JSON default for agents; aredirect()is honored — the "401 → login" pattern; every other status is preserved, no soft-404s). - Mounted routes:
mount shop.tienda [at "/store"]mounts a module'sexport routesgroup — split a big serve into files (serve level only, not insidehostblocks). Per-routerate_limit/timeoutinside a group work since engine v0.6.19 (own zone per mounted route; a prefix is another zone);stream/socketroutes stay in the serve block, andsynsema checkrefuses them in a group beforeservedoes. See Modules. - CORS:
cors "*". Negotiation:content()serves HTML/Markdown/JSON from one source. - Discovery:
/llms.txt,/robots.txt,/sitemap.xml,/openapi.jsonand/docsare generated from the route table — see Discovery below. - gzip for dynamic responses (render/html/content/JSON ≥ 1 KB) when the client accepts it.
- TLS / automatic HTTPS via CLI flags (
--domain … --tls-auto); HTTP/2, vhosts, reverse proxy. - Dev loop:
synsema serve app.syn --watchrestarts on.synchanges (templates/statics already hot-reload per request);render("literal.html")paths are validated at startup. - Observability:
log/printreach the terminal with a[serve]prefix.
Discovery — what every server publishes§
A Synsema server describes itself. Everything below is derived from what is actually wired — the route table, expect, requires auth, rate_limit, describe, and the capabilities the routes' code declares. Nothing is written twice, and what can't be derived truthfully (a response schema) is left out, not invented.
| URL | What | Notes |
|---|---|---|
/llms.txt | Markdown index for agents: title, intent, every endpoint with the capabilities it may use (- POST /pay [net:api.stripe.com, llm]), the describe api: list, and a ## Machine-readable section pointing at the documents below | on by default |
/robots.txt | Allow: / + Sitemap: <base>/sitemap.xml — Disallow: / when private | |
/sitemap.xml | The pages a crawler can visit without context: GET routes without path params, without requires auth, not stream, not proxy | parametric routes (/blog/:slug) are not expanded — the runtime doesn't know which slugs exist. No lastmod (there is no truth to give it) |
/openapi.json | OpenAPI 3.1 of the route table (mapping below) | deterministic: paths ascending, methods in the order GET/POST/PUT/PATCH/DELETE — diffable, anchorable in tests |
/docs | The API, browsable: every operation with its schema, a form per path parameter and body, and Try it (a bearer-token field kept in the tab's sessionStorage; cookies travel on their own). No CDN, no third-party script. With Accept: text/markdown the same reference comes back as Markdown for agents | docs off turns only this page off |
/.well-known/synsema-auth | How to authenticate here — Agent identity |
Base URL (the absolute links in sitemap/robots and OpenAPI servers): domain "…" of the serve block if declared; otherwise the request's Host, with https when TLS is on or a proxy in front sends X-Forwarded-Proto: https.
Behind a proxy, declare domain. A proxy rewrites Host to the backend's authority (nginx's default $proxy_host, the built-in proxy to as well), so without domain the sitemap would say https://127.0.0.1:8090/.... X-Forwarded-Host is ignored on purpose: any client can inject it and a generic proxy passes it through — that is host header injection, and it would poison the sitemap, robots.txt and the OpenAPI servers. Same stance as X-Forwarded-For for rate limiting: the engine only trusts what you declared.
Shape and 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
route "POST /orders" requires auth
expect body {book: text, qty: number}
give {"ok": true}
describe about:/api:/version:are the only inputs you write. Anapi:entry whose prefix is exactly"POST /orders"becomes that operation'sdescription; the program'sintent:is the API description.privatehides all four generated documents (as it always hid/llms.txt);docs offhides only/docs.- A declared
routeor a static file at any of these paths wins over the generated one. describe,privateanddocsare soft keywords — special only inside a serve block.
How /openapi.json is derived:
| OpenAPI | From |
|---|---|
info.title / info.description / info.version | describe about: → intent: → "Synsema service" / intent: / describe version: |
servers | the base URL above (none when it can't be known) |
paths./a/{id} + parameters | route "GET /a/:id" — :id and *rest become {id} / {rest}, in: path, required, string |
requestBody | the route's top-level expect body {…} → a JSON schema, every field required (text→string, number→number, bool→boolean, list→array, map→object). An expect inside a when is a branch, not a contract: not published |
responses.200 | application/json; text/event-stream for a stream route; text/html when the last give is html()/render()/page(); the three negotiated types for content(); 302 for redirect(). Inferred from the code, no response schema — that is the honest limit |
400 / 401 / 429 | an expect / requires auth / a rate limit |
security + components.securitySchemes | routes with requires auth when the block has auth with: bearer, cookie, httpsig (RFC 9421) — the same three /.well-known/synsema-auth announces |
operationId | get_books_id (method + path segments) |
x-synsema-rate-limit | {count, window} (window in seconds; a route inherits the block's) or "unlimited" |
x-synsema-streaming / x-synsema-proxy | stream routes / proxy to routes |
x-synsema-capabilities | what the route may touch, statically: the require lines of every task the route's code calls (transitively, through modules too) plus what the builtins it calls imply (fetch → net, sql → db, read_file → file.read, reason/decide → llm, remember → memory, …). It is the contract, not a trace of what ran — the runtime still gates every call. Always present; [] when nothing |
Mounted groups (mount m.api at "/v1") are published with their prefix. Each host "…" block publishes its own table under its Host.
In CI, without a server: synsema openapi app.syn --out openapi.json [--base-url https://api.example] writes the same document from the source — parse only, nothing runs, no port opens; exit 2 when the file has no serve. See CLI.
See Frontend for HTML pages and Build a website for the end-to-end walkthrough.