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.
Handler form
Section titled “Handler form”service <Name> from http { on <METHOD>("<route>") (<params>) -> Effect[HttpResult[T]] { … }}- Methods:
GET,POST,PUT,PATCH,DELETE. - Route: must start with
/; a:namesegment is a path parameter. - Parameters: each parameter is either a path parameter (matching a
:namesegment) or the specialbodyparameter. A path parameter’s type must be constructible from a string (bynk.http.path_param_not_stringy);GETandDELETEmay not take abody(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.
HttpResult variants
Section titled “HttpResult variants”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).
2xx success
Section titled “2xx success”| Variant | Status | Payload |
|---|---|---|
Ok(value) | 200 | the value, as JSON |
Streaming(stream) | 200 | a Stream[String], SSE-framed (see Streamed responses) |
Raw(body, contentType) | 200 | a Bytes body under the given content-type (see Raw responses) |
Created(value) | 201 | the value, as JSON |
Accepted(value) | 202 | the value, as JSON |
NoContent | 204 | none |
3xx redirection
Section titled “3xx redirection”A redirect carries the target URL, emitted as a Location header with an empty
body.
| Variant | Status | Payload |
|---|---|---|
MovedPermanently(url) | 301 | Location header |
Found(url) | 302 | Location header |
SeeOther(url) | 303 | Location header |
TemporaryRedirect(url) | 307 | Location header |
PermanentRedirect(url) | 308 | Location header |
4xx client error
Section titled “4xx client error”| Variant | Status | Payload |
|---|---|---|
BadRequest(message) | 400 | message |
Unauthorized | 401 | none |
Forbidden | 403 | none |
NotFound | 404 | none |
MethodNotAllowed | 405 | none |
NotAcceptable | 406 | none |
RequestTimeout | 408 | none |
Conflict(message) | 409 | message |
Gone | 410 | none |
LengthRequired | 411 | none |
PayloadTooLarge(message) | 413 | message |
UnsupportedMediaType(message) | 415 | message |
UnprocessableEntity(message) | 422 | message |
TooManyRequests(message) | 429 | message |
UnavailableForLegalReasons(message) | 451 | message |
5xx server error
Section titled “5xx server error”| Variant | Status | Payload |
|---|---|---|
ServerError(message) | 500 | message |
NotImplemented(message) | 501 | message |
BadGateway(message) | 502 | message |
ServiceUnavailable(message) | 503 | message |
GatewayTimeout(message) | 504 | message |
Lifting an Option with ? (v0.153)
Section titled “Lifting an Option with ? (v0.153)”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.
Method semantics
Section titled “Method semantics”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:
| Request | Answer |
|---|---|
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/:code | the GET status and headers, with an empty body |
OPTIONS /links/:code | 204 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 a405(not a404), carrying the derivedAllowheader (RFC 9110 §15.5.6). A request to a path that does not exist is still a404.OPTIONS— a plain (non-preflight)OPTIONSto a known path is a204carryingAllow. (A CORS preflight — anOPTIONSbearingAccess-Control-Request-Method— is answered by the CORS machinery instead, with theAccess-Control-*grant.)HEAD— aHEADto aGETroute runs theGEThandler and returns its status and headers with an empty body (RFC 9110 §9.3.2). Because the handler runs, the headers are exactly aGET’s; aStreamingGETanswered asHEADreturns the stream’s headers without draining it.content-lengthis 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.
Caching
Section titled “Caching”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.
Automatic revalidation (ETag / 304)
Section titled “Automatic revalidation (ETag / 304)”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:
| Request | Answer |
|---|---|
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-Match | 200 + 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.
Declared freshness (@cache)
Section titled “Declared freshness (@cache)”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.
| Field | Meaning | Default |
|---|---|---|
maxAge | The freshness window, as a Duration — emitted as Cache-Control: max-age in whole seconds. Required. | — |
scope | public 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.
Streamed responses
Section titled “Streamed responses”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 responses
Section titled “Raw responses”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
OPTIONSpreflight against any of its route paths — answered with204and theAccess-Control-*headers, before theby/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(plusVary: Origin) stamped onto every response of the service — uniformly across theOk/Raw/redirect/error/stream variants.
Fields
Section titled “Fields”| Field | Meaning | Default |
|---|---|---|
origins | The allowed origins, as string literals — an exact allowlist (["https://a.com", "https://b.com"]), or the wildcard ["*"]. Required. | — |
headers | The Access-Control-Allow-Headers a preflight advertises. | content-type (plus authorization when the service has a Bearer route) |
credentials | Whether credentialed requests (cookies / Authorization) are allowed — sends Access-Control-Allow-Credentials: true. | false |
maxAge | How 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.
Origin matching
Section titled “Origin matching”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 credentials + wildcard rule
Section titled “The credentials + wildcard rule”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.
Security headers
Section titled “Security headers”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 block | Response 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) |
Fields
Section titled “Fields”| Field | Meaning | Default |
|---|---|---|
nosniff | Stamp X-Content-Type-Options: nosniff. Set false to opt out entirely. | true (on) |
hsts | Opt 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.
HSTS is opt-in for a reason
Section titled “HSTS is opt-in for a reason”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.
What is deliberately excluded
Section titled “What is deliberately excluded”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.
Request body limits
Section titled “Request body limits”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:
| Request | Answer |
|---|---|
Content-Length ≤ N | the body is read and validated, then the handler runs |
Content-Length > N | 413 PayloadTooLarge — { kind: "PayloadTooLarge", details: "…" }, the body never read |
| a route with no effective cap | the body is read as before — byte-for-byte unchanged output |
The limits { } section
Section titled “The limits { } section”| Field | Meaning | Default |
|---|---|---|
maxBody | The byte ceiling for the service’s body-taking routes, as a positive Int byte count. | — (no cap) |
The @limit override
Section titled “The @limit override”@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:
| Field | Meaning | Default |
|---|---|---|
maxBody | The byte ceiling for this route, as a positive Int byte count. Overrides limits { maxBody }. | — |
Precedence and the opt-in posture
Section titled “Precedence and the opt-in posture”The effective cap is route @limit first, then service limits, then none:
- a route with a
@limituses 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 byte count
Section titled “maxBody is a byte count”maxBody is a positive Int byte count — 1_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.
Request lifecycle
Section titled “Request lifecycle”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).
Example
Section titled “Example”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 }}Emission
Section titled “Emission”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.