Skip to content

HTTP

HTTP handlers are declared in a service inside a context. See the grammar for HTTP handlers for the production and the diagnostics that govern it.

service <Name> from http {
on <METHOD>("<route>") (<params>) -> Effect[HttpResult[T]] {
}
}
  • Methods: GET, POST, PUT, PATCH, DELETE.
  • Route: must start with /; a :name segment is a path parameter.
  • Parameters: each parameter is either a path parameter (matching a :name segment) or the special body parameter. A path parameter’s type must be constructible from a string (bynk.http.path_param_not_stringy); GET and DELETE may not take a body (bynk.http.body_on_get_or_delete).
  • Return type: must be Effect[HttpResult[T]] (bynk.http.return_not_effect_http_result).

A body parameter is parsed from the request JSON and validated before the handler runs; an invalid body is rejected with 400 at the boundary.

The vocabulary tracks the common, modern HTTP status codes (RFC 9110). A variant’s payload is one of six shapes: the value T as JSON (Value), a target URL emitted as a Location header (Location), an explanatory message as an { "error": … } JSON body (Message), a Stream[String] emitted as an SSE (text/event-stream) body (Streamed), a raw Bytes body under an author-declared content-type (Raw), or no body at all (None).

VariantStatusPayload
Ok(value)200the value, as JSON
Streaming(stream)200a Stream[String], SSE-framed (see Streamed responses)
Raw(body, contentType)200a Bytes body under the given content-type (see Raw responses)
Created(value)201the value, as JSON
Accepted(value)202the value, as JSON
NoContent204none

A redirect carries the target URL, emitted as a Location header with an empty body.

VariantStatusPayload
MovedPermanently(url)301Location header
Found(url)302Location header
SeeOther(url)303Location header
TemporaryRedirect(url)307Location header
PermanentRedirect(url)308Location header
VariantStatusPayload
BadRequest(message)400message
Unauthorized401none
Forbidden403none
NotFound404none
MethodNotAllowed405none
NotAcceptable406none
RequestTimeout408none
Conflict(message)409message
Gone410none
LengthRequired411none
PayloadTooLarge(message)413message
UnsupportedMediaType(message)415message
UnprocessableEntity(message)422message
TooManyRequests(message)429message
UnavailableForLegalReasons(message)451message
VariantStatusPayload
ServerError(message)500message
NotImplemented(message)501message
BadGateway(message)502message
ServiceUnavailable(message)503message
GatewayTimeout(message)504message

Inside a handler returning HttpResult[T] (or Effect[HttpResult[T]]), the ? operator lifts an Option: Some(v) yields v, and None early-returns NotFound (404). This collapses the outer half of the ubiquitous read → respond pyramid — a lookup that misses becomes a 404 without a match:

on GET("/links/:code") (code: Slug) -> Effect[HttpResult[String]] by v: Visitor given Kv {
let stored <- Kv.get(code.value) -- stored : Option[String]
let raw = stored? -- None → 404 NotFound; Some(s) → s
Ok(raw)
}

? on an Option is accepted only where the enclosing return is an HttpResult — elsewhere an Option has no error channel, and the checker rejects it with bynk.types.question_option_outside_http (use .okOr(err) to turn an Option into a Result in a Result-returning function). A Result under ? is unchanged: its Err propagates as before. Mapping a domain error into an HttpResult — the Result → HttpResult direction — is a separate, later capability; ? does not yet convert an arbitrary Err into a status.

The methods a path answers are derived from the routes you declare — there is nothing to write. For a service declaring POST /links and GET /links/:code, the router synthesises, from that table alone:

RequestAnswer
GET /links (a live path, wrong method)405 with Allow: OPTIONS, POST
OPTIONS /links (a plain, non-preflight OPTIONS)204 with Allow: OPTIONS, POST
HEAD /links/:codethe GET status and headers, with an empty body
OPTIONS /links/:code204 with Allow: GET, HEAD, OPTIONS
DELETE /links/:code (wrong method)405 with Allow: GET, HEAD, OPTIONS
GET /nope (no such path)404 — unchanged

The allowed-method set for a path is the union of the methods declared on it, plus OPTIONS always, plus HEAD whenever GET is present. So:

  • 405 + Allow — a request to a live path with an undeclared method is a 405 (not a 404), carrying the derived Allow header (RFC 9110 §15.5.6). A request to a path that does not exist is still a 404.
  • OPTIONS — a plain (non-preflight) OPTIONS to a known path is a 204 carrying Allow. (A CORS preflight — an OPTIONS bearing Access-Control-Request-Method — is answered by the CORS machinery instead, with the Access-Control-* grant.)
  • HEAD — a HEAD to a GET route runs the GET handler and returns its status and headers with an empty body (RFC 9110 §9.3.2). Because the handler runs, the headers are exactly a GET’s; a Streaming GET answered as HEAD returns the stream’s headers without draining it. content-length is omitted (the body is never materialised — permitted).

HEAD and OPTIONS are not declarable methods — there is no on HEAD/on OPTIONS to write; they are synthesised. This is a router correctness property with no configuration and no “off”: every from http service answers its method contract. The 405/OPTIONS answers are produced before the by/Bearer auth seam (method discovery and method rejection are credential-less); a HEAD runs the GET handler and so runs GET’s auth seam unchanged.

A GET response splits into two halves — a validator (has this changed?) and a freshness window (is stale data acceptable, and for how long?). The validator is derivable from the bytes the handler already produces, so the compiler synthesises it; the freshness window is a judgement only you can make, so you declare it. Nothing else.

Every eligible GET — one returning the JSON Ok variant — carries a synthesised weak ETag over its serialised body. When a client re-requests with a matching If-None-Match, the router answers 304 Not Modified with an empty body, saving the transfer. This is on by default, with nothing to write:

RequestAnswer
GET /links/:code (first call)200 + body + ETag: W/"…"
GET /links/:code, If-None-Match: W/"…" (unchanged)304, empty body, same ETag
GET /links/:code, a stale If-None-Match200 + body + the new ETag

Only the Ok variant is eligible — Streaming, Raw, redirects, and error variants carry no ETag and are never answered 304 (a stream has no hashable body; the rest have no representation to validate). The validator is weak (W/"…"): it asserts semantic equivalence of the representation, which is what revalidation needs. Because it is a content hash, a 304 still runs the handler and serialises the body — it saves bandwidth, not server work. A cheaper validator that short-circuits before the handler (e.g. a Last-Modified from a store timestamp) is a planned follow-on.

The 304 is a router response synthesised from the request, not an HttpResult variant — there is no NotModified to return. Like the CORS preflight, it is stamped with Access-Control-Allow-Origin for a CORS service, so a cross-origin revalidation stays readable by the browser.

To let a client or CDN serve a response without revalidating for a window, annotate the handler with @cache, written immediately before on GET:

service links from http {
@cache(maxAge: 5.minutes)
on GET("/links/:code") (code: Slug) -> Effect[HttpResult[Url]] by v: Visitor given Kv { … }
}

This emits Cache-Control: private, max-age=300 alongside the automatic ETag. @cache is the first handler-position annotation; it is legal only on a GET handler returning Ok.

FieldMeaningDefault
maxAgeThe freshness window, as a Duration — emitted as Cache-Control: max-age in whole seconds. Required.
scopepublic or private. private lets only a client’s own cache store the response; public also lets a shared cache / CDN store it.private

The private default is the safe one: a shared cache never stores a response unless you opt into public. A GET with no @cache still carries its ETag (so it is revalidatable) but emits no Cache-Control. Duration units are plural — 5.minutes, 1.hours, 30.seconds — so a singular 1.hour is a bynk.http.cache_bad_max_age diagnostic; @cache on a non-GET (or streaming) handler is bynk.http.cache_on_non_get.

Streaming(stream) returns a 200 whose body is a Stream[String], emitted as Server-Sent Events (content-type: text/event-stream). Each stream element becomes one SSE event — data: <element>\n\n — so a handler can send an incremental feed without buffering the whole response:

on GET("/ticks") () -> Effect[HttpResult[()]] by v: Visitor {
Streaming(Stream.of(["tick-1", "tick-2", "tick-3"]).take(3))
}

A streamed handler returns Effect[HttpResult[()]] — the JSON body parameter T is unused, since the body is the stream. A response commits its status and headers before the first chunk, so streaming is 200-only: handle a pre-stream failure by returning an ordinary variant instead of Streaming (NotFound, Unauthorized(…), …), which share HttpResult[()] and so sit in the same handler with no type conflict:

on GET("/feed/:mode") (mode: String) -> Effect[HttpResult[()]] by v: Visitor {
if mode == "live" {
Streaming(Stream.of(events).take(100))
} else {
NotFound
}
}

Raw(body, contentType) returns a 200 whose body is a raw Bytes, written straight into the response under the content-type you give — no codec runs. This is the service-tier escape hatch for non-JSON bodies: robots.txt, sitemap.xml, .well-known documents, RSS/Atom feeds, a CSV download, a QR-code PNG. Bynk serves bytes with a content-type; it does not template HTML (that is the frontend tier).

Bytes is binary-first, so a PNG flows in directly and text goes through Bytes.fromUtf8 — which makes the UTF-8 charset an explicit author decision rather than a runtime guess:

on GET("/sitemap.xml") () -> Effect[HttpResult[()]] by v: Visitor {
let xml = "<?xml version=\"1.0\"?><urlset></urlset>"
Raw(Bytes.fromUtf8(xml), "application/xml")
}

Like Streaming, Raw returns Effect[HttpResult[()]] — the JSON body parameter T is unused — and is 200-only: it serves service-tier bodies, which are overwhelmingly 200. A custom-status raw body (a 404 with an HTML error page) is a presentation concern held out of scope; a Raw branch and an ordinary pre-body variant share HttpResult[()], so they coexist in one handler.

The content-type is an opaque String, unvalidated — you own it. If you write Bytes.fromUtf8(s), the body is UTF-8, so a content-type claiming a different charset would mislead; keep them in step.

A producer that can fail mid-stream carries its outcome in-band — build a Stream[Result[String, E]] and .map it to Stream[String], encoding an Err as an error event — because the HTTP status is already sent once streaming begins. A bounded take is the language-level guard against an unbounded response. A structured event type (named event/id/retry fields) is a planned follow-on; v1 streams plain String events.

A from http service is same-origin by default: a browser page served from a different origin cannot read its responses. A cross-origin preflight is answered (a plain OPTIONS gets 204 + Allow — see Method semantics), but without the Access-Control-* grant, so the browser still blocks the read. To make a service cross-origin callable, declare a cors { } policy in header position — before the routes:

service api from http {
cors {
origins: ["https://app.example.com"],
credentials: false,
maxAge: 1.hours,
}
on GET("/items/:id") (id: Slug) -> Effect[HttpResult[Item]] by v: Visitor given Kv { … }
}

From this the compiler synthesises, for that service:

  • an OPTIONS preflight against any of its route paths — answered with 204 and the Access-Control-* headers, before the by/Bearer auth seam (a preflight is credential-less by spec, so it must not be rejected by an actor check); and
  • Access-Control-Allow-Origin (plus Vary: Origin) stamped onto every response of the service — uniformly across the Ok/Raw/redirect/error/stream variants.
FieldMeaningDefault
originsThe allowed origins, as string literals — an exact allowlist (["https://a.com", "https://b.com"]), or the wildcard ["*"]. Required.
headersThe Access-Control-Allow-Headers a preflight advertises.content-type (plus authorization when the service has a Bearer route)
credentialsWhether credentialed requests (cookies / Authorization) are allowed — sends Access-Control-Allow-Credentials: true.false
maxAgeHow long a browser may cache the preflight, as a Duration — sent as Access-Control-Max-Age seconds.omitted (browser default)

Access-Control-Allow-Methods is not a field: it is derived from the service’s routes (their methods, plus HEAD where GET is present, plus OPTIONS) — the same derivation that drives the Allow header — so it can never drift from what the service actually serves.

An exact allowlist compares the request’s Origin and reflects the matched value (it never echoes an unvalidated origin), adding Vary: Origin so a shared cache does not serve one origin’s grant to another. A request from an origin not on the list gets no Access-Control-Allow-Origin — the browser blocks the read (fail closed). The wildcard ["*"] answers every origin with a literal * (and needs no Vary).

The Fetch spec forbids Access-Control-Allow-Credentials: true alongside a wildcard origin — a browser rejects that combination at runtime. Bynk catches it at compile time (bynk.http.cors_wildcard_credentials): with credentials: true, list the exact origins instead of ["*"].

A cors { } block is only valid on a from http service (bynk.http.cors_not_http), and a service declares at most one.

A from http service is secure by default: X-Content-Type-Options: nosniff is stamped on every response, with no opt-in required. That header stops a browser MIME-sniffing a JSON or text body into HTML and executing it — a real attack on an API that serves data, and one with no downside, so it is on by default. The one header with a genuine footgun — HSTS — is a deliberate opt-in.

Declare a security { } policy in header position, beside cors { }:

service api from http {
security {
hsts: 180.days, -- opt-in; omit to send no HSTS
nosniff: true, -- the default — set false only to opt out
}
on GET("/orders/:id") (id: Slug) -> Effect[HttpResult[Order]] by v: Visitor given Kv { … }
}

The wire behaviour, for any response of the service:

security blockResponse security headers
none (no block)X-Content-Type-Options: nosniff
security { hsts: 180.days }nosniff + Strict-Transport-Security: max-age=15552000
security { nosniff: false }(none — explicitly opted out)
FieldMeaningDefault
nosniffStamp X-Content-Type-Options: nosniff. Set false to opt out entirely.true (on)
hstsOpt in to Strict-Transport-Security, as a Duration — sent as max-age seconds. Omit to send no HSTS.omitted

The headers are stamped uniformly across every response family — the Ok/Raw/ redirect/error/stream variants, and the synthesised preflight, 405/OPTIONS, and 304 — composing with the CORS headers (the two sets are disjoint). A header already set at the edge (e.g. by Cloudflare) is overwritten with the policy’s value.

Strict-Transport-Security pins a browser to HTTPS for its whole max-age, cached and hard to undo. That breaks a custom domain served over plain HTTP in dev or staging, and on a platform like Cloudflare TLS/HSTS is frequently owned at the edge — so Bynk never enables it for you. hsts emits max-age only; includeSubDomains and preload are each their own pinning footgun and are not offered. A team terminating HSTS at the edge simply omits hsts and keeps the default nosniff.

Content-Security-Policy and X-Frame-Options constrain the script and framing of markup. Bynk serves bytes and a content-type; it does not template HTML, so those headers would have nothing here to govern — shipping them would be cargo-cult. If Bynk ever serves HTML, the security { } section is where they would land.

A security { } block is only valid on a from http service (bynk.http.security_not_http), and a service declares at most one.

A body-taking route (POST/PUT/PATCH) with an unbounded body is a denial-of-service surface: a client can stream an arbitrarily large payload the service reads into memory before it can reject it. Declare a byte ceiling and Bynk rejects an oversized request with a synthesised 413 PayloadTooLarge before the body is read — the same “reject at the boundary” posture as the method-semantics 405.

Set a service-wide default with a limits { } section in header position, beside cors { } and security { }; override it per route with a @limit handler annotation, written immediately before on (the @cache placement):

service uploads from http {
limits {
maxBody: 1_048_576, -- 1 MiB — the default for every body-taking route
}
@limit(maxBody: 26_214_400) -- 25 MiB — this one route accepts a larger payload
on POST("/files") (body: FileMeta) -> Effect[HttpResult[Slug]] by u: Uploader given Kv { … }
on PATCH("/files/:code") (code: Slug, body: FilePatch) -> Effect[HttpResult[()]] by u: Uploader given Kv { … }
}

Here POST /files caps at 25 MiB (its @limit wins), and PATCH /files/:code falls back to the service default of 1 MiB.

The wire behaviour, for a body-taking route with an effective cap of N bytes:

RequestAnswer
Content-LengthNthe body is read and validated, then the handler runs
Content-Length > N413 PayloadTooLarge{ kind: "PayloadTooLarge", details: "…" }, the body never read
a route with no effective capthe body is read as before — byte-for-byte unchanged output
FieldMeaningDefault
maxBodyThe byte ceiling for the service’s body-taking routes, as a positive Int byte count.— (no cap)

@limit(maxBody: <Int>) overrides the service default for one route. It is legal only on a POST/PUT/PATCH handler — a body-taking method — and at most once per handler:

FieldMeaningDefault
maxBodyThe byte ceiling for this route, as a positive Int byte count. Overrides limits { maxBody }.

The effective cap is route @limit first, then service limits, then none:

  • a route with a @limit uses it (its cap wins over the service default);
  • otherwise a body-taking route uses the service limits { maxBody };
  • with neither, the route has no cap — it reads the body as before and emits byte-for-byte unchanged output.

Body limits are therefore opt-in (the CORS posture, not the security default-on posture): a service that declares no limits { } and no @limit is unaffected. The synthesised 413 is stamped with the CORS and security headers (via the same applyCors/applySecurityHeaders pass as every other response), so a cross-origin caller can read it.

maxBody is a positive Int byte count1_048_576 for 1 MiB, 26_214_400 for 25 MiB (the digit separators are just visual grouping). There is no byte Size literal (1.mb) yet — a Size literal, mirroring the Duration playbook, is a named follow-on.

It is a Content-Length fast-reject, not a hard guarantee

Section titled “It is a Content-Length fast-reject, not a hard guarantee”

Enforcement reads the request’s Content-Length header and rejects before any body read. Because Content-Length can be absent (a chunked transfer) or spoofed, this is a cheap first line of defence, not a hard guarantee — it pairs with the Workers platform’s own request-size cap. A streamed-read cap that counts bytes as they arrive is a named follow-on.

A limits { } block is only valid on a from http service (bynk.http.limits_not_http), and a service declares at most one (bynk.parse.duplicate_limits). The section’s only field is maxBody (bynk.http.limits_unknown_field), which MUST be a positive Int (bynk.http.limits_invalid_field). A @limit on a GET/DELETE (a bodyless method) is bynk.http.limit_on_bodyless; a duplicate @limit on one handler is bynk.http.limit_duplicate; any argument other than maxBody is bynk.http.limit_unknown_arg; and a non-positive-Int maxBody is bynk.http.limit_bad_max_body.

yes

no

no

yes, OPTIONS

yes, other

no

yes

incoming request

Worker fetch — index.ts router

method + path matches a route?

bind :name path params

path exists under another method?

404

204 + Allow

405 + Allow

body valid?

400 at the boundary

handler runs — returns Effect

HttpResult[T]

HTTP status + JSON body

Validation happens once, at the edge; the handler only ever sees valid input.

Text equivalent: the Worker’s fetch entry point (index.ts) routes the request on method and path. On a match, path parameters are bound and any body is parsed and validated against its refined type — an invalid body is rejected with 400 at the boundary, before the handler runs. The handler then runs as an Effect and returns an HttpResult[T], which is mapped to an HTTP status and JSON body per the table above. When no route matches, a request to a live path under an unhandled method is a 405 + Allow (or 204 + Allow for a plain OPTIONS); only a request to a path that exists under no method is a 404 (see Method semantics).

context notes
service api from http {
on GET("/ping") () -> Effect[HttpResult[String]] by Visitor {
Ok("pong")
}
on GET("/notes/:id") (id: String) -> Effect[HttpResult[String]] by Visitor {
NotFound
}
}

from http services compile to a runnable Cloudflare Worker on the --target workers target (index.ts router, handlers.ts, compose.ts, wrangler.toml). See emission and Target Cloudflare Workers.