Bendo provides body_limit(bytes), timeout(milliseconds), etag() and
cache_control(policy) built-ins. Wrap them in your app's tag and dispatch
through B.builtin, as in middleware. The compiled consumer
in mw-limits.bend shows the complete setup.
The server defaults to a 1 MiB decoded body limit. Config.body_limit is the
transport ceiling. Route, group and guard limits can tighten it; every selected
limit must pass. To allow a larger route, raise the server ceiling and put lower
limits on the other routes. Limits count octets, including binary bodies.
Ordinary middleware runs after buffering. For early rejection, use
B.serve_limits_with with a pure selector that extracts limits from your tags:
def limit(tag: Tag) -> Maybe<&2, U32>:
match tag:
case Builtin{id}: B.builtin_body_limit(id)
case _: None{}Replace B.serve_with(~Vars, ~Tag, ~Env, ~handler, ~middleware, table, env, config)
with B.serve_limits_with(~Vars, ~Tag, ~limit, ~Env, ~handler, ~middleware, table, env, config). No handler or continuation goes in the route table.
The selector must return the same limits your middleware enforces.
Register Builtin{B.body_limit(4096)} through use, use_path, or a scoped
request/parse/transform/before-handle hook. B.route_hooks, B.group and
B.guard retain their structural route membership. Response-stage hooks do
not control request admission.
Admission resolves the original method and path before request hooks execute. It takes the minimum of the server ceiling and every limit in that request's request/pre-handler plan. It uses the existing framing reader: oversized Content-Length is refused before reading the body, and each chunk size is checked against the remaining budget before its data is accepted. The receive buffer may already contain later wire bytes, but those bytes never enter the decoded body accumulator. Rejection uses the existing bounded discard/close.
Request hooks may rewrite the method or path. Original-route admission still
applies, and middleware also checks the buffered body against the routed
limits. A rewrite from a small route cannot raise its admission budget. A
rewrite to a smaller route receives 413 from middleware. With network-free
B.handle_with, only the buffered middleware check applies. Request-stage
short circuits retain the provisional scopes, as in the lifecycle contract.
Route-limit 413 responses negotiate text or JSON through C.error; parser and
server-ceiling rejections keep the transport error policy.
Put Builtin{B.timeout(1000)} around the work to bound, through ordinary
middleware or a pre-handler hook. It races next against IO.within and renders
504 through C.error, including Accept negotiation. Response hooks, validation,
request-ID/Server-Timing finalization and deferred observers still run normally.
The budget covers the inner chain, not body reading, later response hooks or
socket writes. The transport's read deadlines remain separate.
This timeout is cooperative. IO.within does not cancel its losing computation.
A timed-out handler can still update stores, write files or perform other side
effects. Its late response is discarded. A deadline cannot interrupt
uninterrupted pure computation: the event loop advances only at the next
effect. The client can therefore receive 504 after pure work has already
exceeded the budget. Cancellation and a hard wall-clock response deadline are
never promised. Keep pure handlers and store updates bounded.
Builtin{B.etag()} hashes the final buffered body with pure SHA-256, encodes the
digest as unpadded base64url and quotes it as a strong entity-tag. It replaces
an existing validator. Only status 200 GET/HEAD responses qualify. HEAD hashes
the same full representation as GET, before the writer removes its payload.
Other methods and statuses pass through unchanged. Responses marked as streams
also pass through unchanged, before hashing or conditional evaluation. Their
producer owns the body; the empty buffered placeholder is not a representation.
Prefer map_response after all body transformations, including pretty JSON.
With ordinary onion middleware, put ETag outside body-transforming middleware.
Later changes to the body would invalidate the validator. Do not install ETag
before a later error recovery that replaces a 200 body without removing its
validator. See streaming for producer and HEAD behavior.
Hashing is synchronous work proportional to body size on every qualifying
request, including HEAD and conditional hits that return 304. The built-in does
not cache digests. For large static content, prefer a precomputed weak validator
from representation metadata and use Conditional.matches directly.
If-None-Match uses weak comparison under RFC 9110 section 13.1.2: W/ is
case-sensitive and ignored for comparison; opaque tag contents are compared
case-sensitively. * matches an existing valid validator, lists can contain
empty elements, and commas inside tags remain data. Backslash is literal,
not a quoted-string escape. Repeated fields are comma-joined in arrival order.
Malformed fields or validators do not match, including a list containing an
otherwise matching tag plus an invalid member.
A match returns 304 with no body or representation witness. Construction keeps exactly ETag, Cache-Control, Vary, Date, Content-Location and Expires in their original order, including duplicates. It drops content and framing headers and other application fields. Framework observability finalization can then add request ID and timing; the Date boundary supplies Date when absent. The writer emits no Content-Length for this 304, including HEAD.
src/http/conditional.bend (Conditional) is the canonical If-None-Match module.
It is pure and independent of middleware/context; BND-45 will adopt it for static
files:
strong(body) -> String: the quoted SHA-256 validator.matches(Maybe<String>, tag) -> Bool: field grammar and weak comparison.headers(headers): cache metadata allowlist.not_modified(res): bodyless 304 construction.
Static-file handlers can use matches with their own valid strong or weak tag
and apply not_modified after adding their cache metadata. They remain
responsible for method/status eligibility, Last-Modified precedence, ranges,
and selecting the current representation. This evaluator covers If-None-Match,
not If-Match or If-Modified-Since.
B.cache_control(policy) sets one Cache-Control field after its inner chain.
Policies are typed, so callers cannot accidentally insert arbitrary header text:
B.cache_no_store():no-store.B.cache_no_cache():no-cache(storage is allowed; reuse requires validation).B.cache_public(seconds, immutable):public, max-age=<seconds>, with, immutablewhen true.B.cache_private(seconds):private, max-age=<seconds>.
These helpers describe caching policy; they do not store responses. Configure
one through map_response after ETag if the same policy should cover both 200
and 304 responses. Avoid public caching for personalized content.
Run bun test --timeout 30000 tests/mw-limits.test.ts. It exercises native HTTP,
raw streamed framing, GET/HEAD streaming exclusions, late store mutation,
elapsed-versus-measured pure-work timings and the pure parser's grammar cases. conditional_if_none_match_spec,
conditional_304_spec, etag_middleware_spec and body_limit_spec in
LAWS.bend cover arbitrary fields/tags, responses, request methods and natural
lengths. The reference evaluator
in proof/limits-spec.bend builds tags and list elements from their suffixes,
while the implementation scans forward into accumulators. It then checks list
grammar and existence of a weak match in separate traversals; the implementation
combines them. The proof shares the
RFC octet class and OWS primitive, not the conditional decision.
etag_middleware_spec checks the actual pure middleware decision against the
independent evaluator: ineligible responses remain exactly unchanged; eligible
buffered responses receive the body ETag and become Conditional.not_modified
exactly when LimitsSpec.matches succeeds. The decision spec shares the hash, header replacement and separately specified
304 constructor, but calls neither the middleware decision nor its field
evaluator.
Early framing rejection is integration-tested with incomplete Content-Length
and chunked requests that must receive 413 without sending the remaining body.
There is no reachable-state accumulator-budget law in this middleware change.