HTTP Server

Build production-ready HTTP APIs and server-rendered applications with Syntecnia's built-in serve primitive — no framework required.

Basic Server

Declare a server with serve on <port>. Routes are nested inside the declaration using indentation, keeping the entire server definition in one readable block.

require serve(8080)

serve on 8080
    route "GET /hello"
        give {"message": "Hello, world!"}

The require serve(8080) declaration tells the runtime to reserve the port at startup, making permission explicit and auditable. The server blocks the process until interrupted; no extra runner is needed.

Routes

Routes are matched top-to-bottom within each method. Three segment kinds are supported: literal, named parameter, and catch-all.

Named parameters

A colon prefix captures a single path segment and exposes it on the params map.

route "GET /products/:id"
    give fetch_product(params.id)

Catch-all segments

A star prefix captures everything from that position to the end of the path, including slashes.

route "GET /files/*path"
    give read_file(params.path)

Matching precedence

When multiple routes could match a request, Syntecnia applies this order:

  1. Exact literal match
  2. Named parameter (:param)
  3. Catch-all (*catchall)

Automatic method handling

If the request path matches a known route but the method does not, Syntecnia returns 405 Method Not Allowed and sets the Allow header to the list of accepted methods — no boilerplate needed.

OPTIONS and HEAD are handled automatically for every defined route: OPTIONS replies with the correct Allow header, and HEAD mirrors the GET handler with the body stripped.

The Request Object

Inside any route handler the name request is a map pre-populated by the runtime. All fields are read-only.

Field Type Description
request.method text HTTP method in upper-case ("GET", "POST", …)
request.path text URL path without query string
request.body text Raw request body as text (up to body limit)
request.json map / list Body decoded from JSON; nothing if body is not valid JSON
request.headers map All request headers; keys are lower-cased
request.user map / nothing Populated by the auth task; nothing when unauthenticated
request.body_file text / nothing Temp file path when body spilled to disk
request.ip text Real peer IP after proxy headers are resolved

Query parameters

Query string values are available as the query map. All values are text; parse them explicitly when a number is needed.

route "GET /search"
    let term = query.q
    give search(term)

Path parameters

Captured route segments are available as the params map. Like query values, all entries are text.

Reading the full body

For streaming uploads or large payloads that spilled to disk, use read_body() to retrieve the complete content regardless of where it is stored.

route "POST /upload"
    let data = read_body()
    give {"size": len(data)}

Response Contract

Syntecnia serialises whatever the route handler gives into an HTTP response automatically. The mapping is:

You give HTTP Response
A map 200 with a JSON object body
A list 200 with {"items": […], "count": N, "total": M, "cursor": next}
A scalar (text, number, bool) 200 with a bare JSON value
Nothing (no give) 200 with null

Response helpers

Use these built-ins when you need a specific status code or content type:

Helper Status Notes
ok(x) 200 Explicit 200; same serialisation rules as give
created(x) 201 For newly created resources
not_found(x) 404 JSON body with the provided value
fail(code, msg) code Arbitrary status with a message string
html(content) 200 Sets Content-Type: text/html
respond(content, content_type, status?) status (default 200) Full control over body, type, and status
route "POST /items"
    let item = create_item(request.json)
    give created(item)

route "GET /items/:id"
    let item = find_item(params.id)
    when item is nothing
        give not_found({"error": "item not found"})
    give item

Pagination

When a route gives a list, Syntecnia automatically paginates it. The default page size is 100 items; the maximum is 1 000 items. Clients control pagination with query parameters:

  • ?limit=N — number of items per page
  • ?cursor=N — opaque cursor for the next page (returned in the previous response's "cursor" field)

The envelope returned for every paginated list is:

{
  "items":  [ /* …page contents… */ ],
  "count":  25,       /* items on this page */
  "total":  412,      /* total matching rows */
  "cursor": 100       /* next-page cursor; absent on last page */
}

SQL push-down with paged()

For database queries use paged() so that the database applies LIMIT/OFFSET and an exact COUNT(*) is computed in a single round-trip instead of fetching the full result set into memory.

route "GET /products"
    give paged(query("SELECT * FROM products ORDER BY id"))
Always prefer paged() for database-backed lists. It issues a COUNT(*) alongside the paginated query and fills "total" accurately without loading all rows.

Authentication

Declare an auth task with auth with <task>. The runtime extracts the Authorization: Bearer <token> header and calls your task with the raw token string. The task should return either the user data (any map) or nothing to signal an invalid token.

auth with check_token

route "GET /me" requires auth
    give user of request

task check_token(token)
    let row = query_one("SELECT * FROM sessions WHERE token = ?", token)
    give row
  • Routes marked requires auth automatically return 401 Unauthorized when the auth task returns nothing.
  • The authenticated user map is available inside the handler as request.user.
  • Routes without requires auth can still read request.user — it will be nothing for unauthenticated requests.
Auth checks run after rate limiting. A request that exceeds the rate limit receives 429 before the auth task is ever called.

Input Validation

Use expect body { … } to declare required fields and their types. Syntecnia validates the request JSON before the handler body runs.

route "POST /products"
    expect body {name: text, price: number}
    give create_product(request.json)

Supported types:

text number bool list map
  • A missing field or a type mismatch returns 400 Bad Request with the offending field name in the error body.
  • Malformed JSON (not parseable at all) is also a 400 before expect body is even evaluated.
expect body validates the presence and type of fields, not their values. Add your own business-logic checks after the declaration.

Static Files

Serve files from the filesystem with a single declaration. No separate file() capability is needed — the static declaration itself is the permission grant.

# Serve everything in ./public at the root URL
static "./public"

# Serve ./assets under /assets prefix
static "/assets" from "./assets"
  • Route precedence: a defined route always wins over a static file at the same path.
  • Path traversal: requests containing .. segments are blocked with 400 before any filesystem access occurs.
  • Appropriate Content-Type headers are set automatically based on the file extension.
Static directories should be outside your source tree in production. Serving your project root risks exposing source files and .env secrets if path traversal protections are bypassed by a future OS/runtime bug.

CORS

Enable Cross-Origin Resource Sharing with a single declaration. Place it at the top of the serve block to apply server-wide, or inside a route to limit its scope.

# Allow all origins (no credentials)
cors "*"

# Restrict to a specific origin
cors "https://app.example.com"
  • Syntecnia handles preflight OPTIONS requests automatically when CORS is enabled.
  • Standard CORS headers (Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers) are added to every matching response.
cors "*" does not work with credentialed requests (withCredentials: true in the browser). Use an explicit origin when cookies or Authorization headers must be sent cross-origin.

SSE Streaming

Use the stream block to push Server-Sent Events to the client. The response is sent as Content-Type: text/event-stream and the connection stays open until the block exits.

route "GET /events"
    stream
        each tick in range(10)
            send {"count": tick}
            send token as "token"    # named event type
  • send <value> emits a data: event line. Maps and lists are JSON-encoded.
  • send <value> as "<type>" emits an event: <type> line before the data line.
  • stream and give are mutually exclusive in the same route handler.
  • Concurrent open streams are capped by max_streams N (default 100). Requests beyond the cap receive 503 Service Unavailable.
serve on 8080
    max_streams 500    # raise the cap server-wide

    route "GET /events"
        stream
            each msg in subscribe("channel")
                send msg
SSE is a one-way, server-to-client protocol over a plain HTTP connection. For bidirectional messaging, use a WebSocket library instead.

Rate Limiting

Syntecnia uses a token bucket algorithm keyed by the real peer IP address (proxy headers are respected). Limits can be set globally or per route.

# Global limit: 100 requests per minute
rate_limit 100 per minute

serve on 8080
    rate_limit 100 per minute    # server-wide default

    route "POST /login"
        rate_limit 5 per minute   # tighter per-route limit
        give authenticate(request.json)

    route "GET /public"
        rate_limit none           # disable for this route
        give {"status": "ok"}

Supported time windows:

second minute hour
  • Requests exceeding the limit receive 429 Too Many Requests with a Retry-After header indicating when the bucket refills.
  • Rate limiting is checked before authentication — an attacker cannot bypass it by probing auth endpoints.
  • rate_limit none disables the limit for a specific route, regardless of the server-wide setting.

Request Body Limits

Syntecnia enforces a 1 MB default body limit to protect against memory exhaustion from large uploads. Change it with max_body.

serve on 8080
    max_body "10mb"     # raise limit to 10 MiB

    route "POST /upload"
        max_body "100mb"   # per-route override
        give handle_upload()
  • Requests whose body exceeds the limit receive 413 Content Too Large before the handler runs.
  • Bodies larger than an internal threshold (currently 256 KB) that are within the limit are spilled to a temporary file on disk; request.body_file holds the path and read_body() reads it transparently.
Setting a very high max_body without a corresponding rate_limit opens a slow-loris vector. Pair large body limits with strict per-route rate limits.

Templates (Server-Side Rendering)

Render HTML templates server-side with the render() built-in. Templates are plain .html files using Syntecnia's minimal, injection-safe syntax.

route "GET /welcome"
    let t = "Welcome"
    give render("page.html", {
        "title": t,
        "items": ["a", "b"]
    })

Template syntax

Template expressions use single curly braces. All values are HTML-escaped by default.

Syntax Purpose
{ title } Output a variable, HTML-escaped automatically
{ each item in items }…{ end } Loop over a list; item is scoped to the block
{ when condition }…{ otherwise }…{ end } Conditional block; { otherwise } is optional
{ raw trusted_html } Output without escaping — use only for known-safe HTML
Do not put CSS rules inline in template files. Because Syntecnia's template engine uses { } as its delimiter, CSS curly braces would be misinterpreted as template expressions. Always link external stylesheets.
Templates are validated for syntax errors at server startup. Attempting to start the server with a broken template will fail fast with a clear error before any request is handled.

Semantic Content

The content() system lets you build structured documents from semantic nodes rather than raw HTML. Syntecnia negotiates the output format based on the client's Accept header or URL suffix.

route "GET /article/:slug"
    let doc = fetch_article(params.slug)
    give content(
        page([
            heading(1, doc.title),
            prose(doc.body),
            link("Back", "/")
        ], {"title": doc.title})
    )

Content nodes

page heading prose list ordered_list link image section code raw

Content negotiation

Client signals Output format
Default (browser request) HTML with embedded JSON-LD structured data
Accept: text/markdown Markdown
URL ends in .json JSON tree of the content nodes
URL ends in .md Markdown
Semantic content is ideal for pages that need to be consumed by both browsers and LLM agents. The JSON-LD output and the .json suffix route give agents clean, structured access without a separate API endpoint.

Agent Discoverability

Syntecnia auto-generates machine-readable discovery files so that LLM agents and crawlers can understand your API without manual documentation.

Automatic files

  • /llms.txt — generated from your intent declaration, route annotations, and describe block. Lists every public route with its method, path, and purpose.
  • /robots.txt — served automatically. Allows crawlers by default.

Enriching /llms.txt with describe

serve on 8080
    describe
        intent  "Product catalogue API"
        contact "api@example.com"
        version "2.1.0"

Disabling discoverability

Add the private keyword to the server declaration to opt out entirely:

serve on 8080 private
    # /llms.txt → 404
    # /robots.txt → Disallow: /
Even in private mode, /robots.txt is still served — it returns Disallow: / so well-behaved crawlers know not to index the server.

Request Isolation

Every inbound request executes in its own interpreter scope. Variables, intermediate values, and any state created during one request are invisible to concurrent requests and discarded when the handler returns.

  • No shared mutable state between handlers. There is no global variable that one request can corrupt for another.
  • Shared persistence is intentional and explicit: use the blackboard for ephemeral cross-request state, or the database for durable storage.
  • Always use parameterised SQL. Syntecnia's query built-ins require parameters to be passed as a separate argument, making accidental string interpolation in SQL structurally impossible.
# Safe: parameter is separate from the query string
let user = query_one("SELECT * FROM users WHERE id = ?", params.id)

# Unsafe: never build SQL by concatenation
# query("SELECT * FROM users WHERE id = " + params.id)  ← SQL injection risk
Even though each request scope is isolated, the underlying database connection pool is shared. Long-running or expensive queries can still block the pool. Use paged() and add indexes for large tables.

Error Handling

Syntecnia's HTTP layer is designed to never crash the server. Every error path maps to a deterministic HTTP response.

Situation Response
expect body validation fails 400 with the failing field name
Request body is not valid JSON 400 before the handler runs
Uncaught error in handler 500 with detail in dev mode; generic message in --secure
Path matched, wrong method 405 with Allow header listing valid methods
No route matches 404
Rate limit exceeded 429 with Retry-After header
Body exceeds max_body 413
SSE streams exceed max_streams 503
In --secure mode, 500 responses never include stack traces or internal error messages. Use --secure for any internet-facing deployment.

Handling errors in handlers

You can intercept errors within a handler using attempt / catch:

route "GET /risky"
    attempt
        give do_something_risky()
    catch err
        give fail(500, err.message)
Errors are logged internally regardless of the response sent to the client. Check the runtime log for full stack traces in development.