Skip to content

Listeners & Serving

By default a Weld server listens on one port and serves every route from it. This page covers the declarations that control how it serves — additional listeners and virtual hosts, CORS, response compression, and health checks with graceful shutdown. They're all written in your .weld source (and, where noted, tuned by environment variables the compiled server reads).

Listeners & virtual hosts

By default a server has one listener on the --port/PORT port. A listen block declares another one, identified by a port, a host (matched against the request's Host header), or both. Bind a route or hub to a listener with on <name> — or wrap a group in an on <name> { … } block so the on isn't repeated per route:

weld
listen admin { port 9090 }                      # a second port
listen api   { host "api.example.com" }         # same default port, matched by Host
listen intl  { host "*.internal.corp" }         # any single subdomain label

on api  {
    route GET "/"      -> string { respond "api root" }
    route GET "/users" -> string { respond "api users" }
}
route GET "/metrics" on admin -> string { respond "weld_up 1\n" }

route GET "/" -> string { respond "default" }   # no `on` → the default listener
  • Ports. A listener with a port binds it (handy for admin/metrics on a separate port). With no port, the listener shares the default --port/PORT port and is told apart by its host. Each named listener also reads <NAME>_PORT at startup (e.g. ADMIN_PORT), falling back to its literal — so ports are configurable per deploy.

  • Virtual hosts. Several listeners may share one port; incoming requests are demultiplexed by the Host header. Matching is case-insensitive and ignores the port (api.example.com:8080 matches api.example.com). A host is an exact name, a bare * (any host), or a single *.suffix wildcard matching exactly one subdomain label (a.example.com ✓, but not example.com or a.b.example.com). A host-specific listener wins over a host-agnostic one sharing the port.

  • Each listener owns its routes. A request is served by exactly one listener — the one its port and Host resolve to — and only that listener's routes match. Routes on the default listener are not a fallback for a host-specific one (this mirrors nginx's server_name); a request to api.example.com above gets a 404 for /metrics, since /metrics is on admin. To share an endpoint across several listeners, bind it to all of them with a list — on [api, web] (or on [api, web] { … } for a group) — so a common /health isn't repeated per host:

    weld
    on [api, intl] {
        route GET "/health" -> string { respond "ok" }   # served for both hosts
    }
  • A gRPC service … on <listener> can't set host (gRPC routes by HTTP/2 :authority, not the Host header). Multiple listeners are threaded-driver only; not --green yet.

CORS

A cors { … } block configures Cross-Origin Resource Sharing for every route on a listener — put it inside a listen block, or at the top level for the default listener. Weld then answers preflight OPTIONS automatically and adds the Access-Control-Allow-* headers to responses; no per-route boilerplate.

weld
cors {
    origin "https://app.example.com", "https://admin.example.com"  # or `origin "*"`
    headers "content-type", "authorization"   # allowed request headers (omit → reflect request's)
    expose  "x-request-id"                     # response headers JS may read
    credentials                                # allow cookies/Authorization
    max_age 86400                              # cache the preflight
}
route GET "/api/users" -> [User] { … }         # inherits the policy
  • Origins may be a static list, "*", or a predicate origin(o => <bool>) that runs for every request's Origin — call your own fns for custom logic (subdomain matching, dynamic allowlists). A matching origin is reflected back with Vary: Origin.
  • Methods default to the methods of the routes on that listener (plus OPTIONS); override with methods GET, POST, ….
  • credentials can't be combined with origin "*" (the spec forbids it) — Weld rejects it at compile time; use an explicit list or a predicate.
  • Preflight is answered automatically, but a route OPTIONS "/path" handler takes precedence — write one to control a specific path's preflight yourself.
  • Per-route override is done with plain headers: cors off opts the route out of the automatic headers, then set your own with header "access-control-allow-origin" … (and, if needed, a route OPTIONS for its preflight). CORS response headers are just HTTP headers.

Response compression

compress on a listener (or at the top level, for the default listener) turns on response compression for every route on it. Per request Weld reads Accept-Encoding and, for text-ish content types whose body is at least min_size bytes, sends the body gzip- or deflate- encoded, adding Content-Encoding and Vary: Accept-Encoding.

weld
compress                       # top level → the default listener (defaults: min_size 256)

listen api {
    port 8080
    compress { min_size 1024 } # or tune it per listener
}

route GET "/report"  on api -> string { type "text/csv"  respond big_csv() }  # compressed
route GET "/logo.png" compress off -> bytes { type "image/png"  respond bytes }  # opted out
  • Negotiation: gzip is preferred over deflate; if the client accepts neither, the body is sent as-is (still with Vary: Accept-Encoding).
  • What's compressed: text/* and json/javascript/xml/svg/wasm/csv/yaml/NDJSON/ event-stream types, only when the body is ≥ min_size (default 256 bytes). Already-compressed media (images, video, zip) is skipped automatically.
  • Per-route override: compress opts a single route in (on a listener that has none), and compress off opts one out.
  • Scope: applies to buffered responses. Chunked/streaming (emit) responses aren't compressed yet. Only gzip/deflate are supported — brotli isn't in the Zig standard library.

Health checks & graceful shutdown

On SIGTERM/SIGINT the server shuts down gracefully rather than dropping connections:

  1. Readiness fails immediatelyready() returns false, so a readiness probe returns 503 and the load balancer stops routing new requests to this instance.
  2. It keeps serving for SHUTDOWN_DELAY_SECONDS (default 0) so that 503 has time to propagate to the balancer before the socket closes — set this to a few seconds behind a load balancer that routes by readiness.
  3. It stops accepting new connections and lets in-flight requests finish, up to SHUTDOWN_GRACE_SECONDS (default 25), then exits.

Expose a readiness endpoint (typically on a private listener) and point your orchestrator's probe at it:

weld
route GET "/readyz" on admin -> string {
    if ready() { respond "ok" } else { status 503  respond "draining" }
}

Liveness is just any always-200 route (/livezrespond "ok"). See examples/health.weld.