Repository navigation
v1.0.0-rc3
Pre-releaseAdded
- Configuration values from the environment or from files. Any string
value inconfig.tomlor an overlay can be"(env) NAME"(an environment
variable) or"(file) PATH"(a file's contents, e.g. a Docker/Kubernetes
secret under/run/secrets/; relative paths are relative to the config
file). This works for every key, including custom sections, and for
rustf-cli. A missing variable or file, or an unknown tag such as
"(evn) PORT", stops the application at startup with an error that names
the key; resolved values are masked in loading errors (always where quoted,
and unquoted from 8 characters).(file)reads
regular files of at most 1 MiB. Framework keys take their declared type
("(env) PORT"becomes an integer); everything else stays a string, and
CONF::get/get_or/get_int/get_float/get_booland
AppConfig::sectionparse such strings when the requested type is a number
or a boolean. A value that must literally start with a marker doubles the
parenthesis:"((env) text".rustf-cli validatenow loads the effective
configuration and checks the references ofconfig.prod.toml.
Templates can readCONF, including referenced values: do not output
secrets there.
Fixed
-
The session cookie's
Secureflag followed the wrong variables. It was
decided fromRUSTF_ENVIRONMENT,RUST_ENVorNODE_ENV— never from
RUSTF_ENVor[app] environment— so an application configured for
production could issue non-Securecookies (any build with
NODE_ENV=development, or a debug build withRUSTF_ENV=production). It now
follows the application's configured environment. -
An unknown environment name no longer runs as development.
RUSTF_ENV=staging,[app] environment = "staging"or a typo silently
selected development — dev overlay, no production security defaults,
detailed error pages. Loading now fails with an error naming the value. -
Workers and events saw a different environment than the application.
They read their own variables (NODE_ENV,RUST_ENV,APP_ENV), ignored
[app] environment, andRUSTF_ENV=prodmade a worker'sis_production()
false. Both now report the configured environment ("development"or
"production"); detailed error pages follow it too, instead of the build
profile. -
The generated
[session]configuration no longer contains keys the
framework ignores.rustf-cli new project(and the sample app) wrote
secret,timeout,secureandhttp_only, none of which is read:
session IDs are random and stored server-side (there is no signing secret),
the timeouts areidle_timeout/absolute_timeout, and cookies are always
HttpOnlyandSecurein production. Changingtimeouthad no effect. The
scaffold now writes the real keys; existing projects should renametimeout
toidle_timeoutand may delete the other three. -
Configuration docs described environment variables that are not read
(RUSTF_SESSION_*,RUSTF_CUSTOM_*, "all values can be overridden with
RUSTF_") and a"${VAR}"syntax that loaded as literal text. They now list
the variables actually read and document"(env) NAME". -
A handler returning
Errnow answers with the error's own status instead of
a blanket500.ctx.require_auth()?is a401,ctx.param_int("id")?on
/posts/abcis a400, and the rest ofError::status_code()'s mapping
(403, 404, 429) applies too. Previously every failing handler flattened
to500 Internal Server Error, which made the idiomatic guard unusable and
left the 401/403/404/429 arms ofError::status_code()as dead code.
The mapping also covers controllerbeforehooks and middleware errors.It runs before the outbound middleware stack, so security headers, CORS and
compression apply to the error response; a response the handler had already
written is discarded in favour of the error's; and a5xxnever discloses
its own message —Error::client_message()returns the error text only for
Validation,InvalidInput,AuthenticationandAuthorization, and
everything else (including anything added withwith_context) collapses to
the canonical reason phrase, with the full chain going to the error log.This changes the status code of every failing handler. Anything asserting
500on a route that returnsErrneeds updating. -
Error::Timeoutmaps to504 Gateway Timeout, not408 Request Timeout.
The framework raises it when a dependency does not answer in time (a worker
run with a timeout, for instance), which is the server's failure, not a slow
client. As a 5xx it is now logged as an error and its message is masked. -
A malformed JSON body is a
400, not a500.ctx.body_json()/
Request::body_as_json()reported a parse failure asError::Internal; it is
nowError::InvalidInput, the same as a form body that does not deserialize.
The client reads onlyInvalid JSON body; the parser's diagnostic, which can
name fields of the server-side type, is logged at debug. -
ctx.body_form_typed()no longer echoes the deserializer's diagnostic to
the client. A form that does not deserialize is still a400, but its body
is nowInvalid form datainstead of serde's text (which named the struct's
fields, their Rust types and enum variants). The diagnostic is logged at
debug. Any client that parsed that message needs updating. -
An early inbound exit still gets every outbound-only middleware. When an
inbound middleware returnedErrorInboundAction::Stop, only the outbound
middleware it had already passed in priority order ran over the response. An
auth middleware at priority-100therefore sent its401— and a rate
limiter at-900its429— withoutSecurityHeadersMiddleware(priority
0). Outbound-only middleware further down the list now runs too; dual-phase
middleware whose inbound phase never ran is still skipped. -
A
SOCKETroute on an app with no middleware at all now upgrades. The
zero-middleware fast path inexecute_middleware_chainreturned before
start_pending_upgraderan, so such an app answered the handshake with101
and then never upgraded. Every builtin middleware is opt-in and the session
middleware is only registered when sessions are enabled, so the path was
reachable.
Added
Request::wants_json()— whether the client would rather have JSON than
HTML: true for anAcceptheader that ranks a JSON type (application/json
or any+jsonsuffix, such asapplication/problem+json) at least as high as
text/html(q-values respected). WhenAcceptnames neither, a classic XHR
(X-Requested-With: XMLHttpRequest) counts as JSON; htmx does not — even
withX-Requested-Withadded by an interceptor — since it swaps the response
into the page.Response::from_error(&Error, json: bool)— builds the response for a
framework error: status fromError::status_code(), body from
Error::client_message(), as plain text or as
{"error": ..., "code": ...}. A5xxreports"code": "E_INTERNAL"
whichever variant failed.Error::client_message()— the text an error may safely show the client.
Changed
RUSTF_ENVis the only environment variable read.RAILS_ENVand
NODE_ENV(documented) andRUSTF_ENVIRONMENT,RUST_ENV,APP_ENV
(undocumented, read by some components) are ignored: a container that sets
them for other tools no longer changes RustF's mode. SetRUSTF_ENVor
[app] environmentinstead.AppConfig::detect_environment()returnsResult<Environment>, and the
newEnvironment::parserejects unknown names.Environment::from_str, which
maps unknown names to development, is deprecated.From<config::SessionConfig>for the session manager'sSessionConfigis
removed; useSessionConfig::from_app_config(&app_config). The session
section alone cannot tell whether the app runs in production, so the
conversion set the cookie'sSecureflag from a guess. Code that built a
customSessionMiddlewarewithapp_config.session.clone().into()now fails
to compile instead of silently issuing non-Securecookies in production.RustF::new()andWorkerManager::new()followRUSTF_ENV(they load
no configuration file). An invalid value is logged and treated as
production, the safe side.DEBUGis no longer aLazy<bool>but aDebugvalue that reads the
current environment on every access (*DEBUGandDEBUG.get()), so it
cannot keep a value computed before the configuration was loaded.routes!no longer acceptsSOCKETas a custom HTTP method. The macro's
catch-all arm used to stringify any identifier, soroutes![SOCKET "/x" => h]
registered a route whose method was literallySOCKET. It now builds a
WebSocket route: the entry registers asGET, a plainGETgets426, and
with--no-default-features(nowebsocketfeature) the invocation does not
compile. Anything genuinely dispatching a customSOCKETmethod must build
the route withRoute::new("SOCKET", path, handler)instead.TrieRouter::add_routetakes aRouteInfoinstead of five positional
arguments.Router::add_route(Route)— what applications and controllers
use — is unchanged.
Added
- WebSocket support, behind the default-on
websocketfeature. Total.js
shaped:routes![SOCKET "/chat/{room}" => chat]registers aGETroute that
only accepts a handshake — a plainGETgets426 Upgrade Requiredand the
handler never runs.ctx.socket(|room| ..)configures the room for the
matched URL for the first client, withon_open/on_message/
on_close/on_errorclosures; everyone after joins it. (Once per room,
not once per process: an autodestroyed room — or one whose closure panicked —
is configured again by the next connection, so keep external side effects out
of it.)SocketRoomhas
send,send_except,send_to, the_jsonvariants,online,find,
autodestroy(on by default) anddestroy;SocketClientcarries the
handshake's params, query, headers, IP and session.SOCKET::room(path)—
RustF'sF.connections[url]— reaches any live room from an HTTP handler, a
worker or a scheduled job. - Per-route socket flags, mirroring Total.js's
ROUTE('SOCKET /x/', fn, ['json'], 1024):
SOCKET "/feed" => feed, [json, max = 1 << 20].[json]closes any message —
text or binary — whose payload is not valid JSON with1003;[max = N]
overrides the configured message cap and closes an oversize message with
1009. Supporting this required rewriting
routes!as an incremental tt-muncher — every existing call site is
unchanged, but a malformedroutes!now produces a less precise error. ctx.websocket(|socket| ..)— the raw escape hatch, oneWebSocketper
connection withrecv/send/send_json/close/split. Control
frames are handled by the runtime: pings are answered automatically, a close
frame endsrecv, and the close handshake is completed rather than the
socket being dropped.[websocket]configuration:max_message_size(1 MiB default, against
tungstenite's 64 MiB),max_frame_size,ping_interval(server-side
keepalive, which closes a connection with1001when nothing comes back
between two pings),allowed_origins,accept_subprotocols,
require_origin(refuse a handshake with noOriginat all — off by
default, since that would break non-browser clients) andmax_connections
(cap on concurrent sockets; over it, a handshake gets503).- Origin enforcement on the handshake. Same-origin by default, since a
WebSocket handshake carries cookies and CSRF tokens do not apply to it.
allowed_originsextends the default rather than replacing it;Origin: null
is never same-origin; a request with noOrigin(a non-browser client) is
allowed;["*"]turns the check off. - Graceful shutdown closes sockets with
1001 Going Awayand waits for the
connections to finish, bounded by[server] shutdown_timeout. Upgraded
connections leave hyper's control, so they are tracked separately. rustf-cli new socket --name <name>scaffolds aSOCKETroute, a room
with the three handlers, a page, and a dependency-free browser client.- Documentation:
docs/ABOUT_WEBSOCKET.mdand the book's
WebSocket chapter, with a Total.js → RustF
mapping table and the limits spelled out (HTTP/1.1 only, no cross-process
broadcast, no permessage-deflate). - Generic typed accessors on
Context:query_as::<T>/param_as::<T>/
body_as::<T>and their_orvariants, for anyT: FromStr. These cover the
types the_int/_boolaccessors do not —f64,i64,u32,Uuid,
Decimal,NaiveDate,DateTime<Utc>,IpAddr— so no call site needs a
hand-written.parse()chain. The error names the parameter and the expected
type:Query parameter 'start' must be a valid NaiveDate. FormDatatyped getters:get_as::<T>,get_as_or::<T>,get_all_as::<T>,
alongside the existingget_str/get_int/get_bool.FormDataandFormValueare exported from the crate root and the
prelude, so a service can take&FormDatawithout reaching into
rustf::http.- Streaming response bodies.
Response.bodyis now aBodyenum —
Body::Full(Vec<u8>)(buffered, the default) orBody::Stream(BodyStream)
(chunks produced on demand). A stream that declares its size is sent with
Content-Length; an open-ended one falls back to chunked transfer encoding.
with_bodyacceptsimpl Into<Body>, so every existingwith_body(vec),
ctx.json()andctx.view()call site compiles unchanged. - Streamed file responses:
ctx.file_download_stream[_from]and
ctx.file_inline_stream[_from](and the matchingResponse::constructors)
serve a file in 64 KiB chunks, so peak memory per request is flat regardless
of file size. Same path-containment rules as the buffered variants. - Server-Sent Events:
ctx.sse(events)/Response::sse(events)take a
Stream<Item = SseEvent>and emit thetext/event-streamwire format with
Cache-Control: no-cacheandX-Accel-Buffering: no.SseEventbuilds one
event (data,id,event,retry, or a comment;SseEvent::jsonfor
serialised payloads). The stream may yieldSseEventor
rustf::Result<SseEvent>(sealed traitSseItem), so a fallible source
can abort the feed with anErr.ctx.sse_with_keep_alive(events, interval)
injects a comment line once per interval while the feed is idle (the first
one a full interval after the stream starts) so proxies do not drop the
connection. Field values are split on\r\n,\nand bare\rinto one
field line each, so an attacker-controlledid,eventordatacannot
forge another field whatever line ending it uses. - Static files above 1 MiB are streamed (
STATIC_STREAM_THRESHOLD).
Smaller assets stay buffered so gzip and other body-inspecting middleware
still apply to stylesheets and scripts. ETag / Last-Modified / 304 handling
is unchanged.Rangerequests are still not supported by the static
server; streaming changes the memory profile, not the protocol surface. HEADrequests. AHEADwith no matchingHEADroute runs theGET
handler; the response keeps the headers (including theContent-Length
the body would have had) and the body is discarded before anything is
sent. Static files behave the same way. No length is invented for an
open-ended stream, and none is added to a1xx,204or304.- Declared stream sizes are enforced. A
Body::from_sized_stream(or
streamed file) that produces more than its declared length is truncated to
it and logged; one that ends short yields an error so the connection is
dropped instead of leaving the client waiting on aContent-Lengththat
will never be satisfied. Body,BodyStreamandSseEventare exported from the prelude.tokio-streamis now a dependency, soReceiverStream::new(rx)is the
idiomatic way to feed an mpsc channel intoctx.sse.
Changed
-
BREAKING —
FormDatapreserves multi-value fields. It wrapped
HashMap<String, String>, so a repeated field (a checkbox group,tags[])
silently kept only one value. It now wrapsHashMap<String, FormValue>:
get(key)returnsOption<&str>(the first value) and the new
get_all(key)returns every value. The
Deref<Target = HashMap<String, String>>impl is gone; useget,get_all,
get_value,contains_key,len,is_empty,keys,iteror
into_inner(). A function that took&HashMap<String, String>from
ctx.body_form()now takes&FormData. -
BREAKING —
ctx.body_form_typed::<T>()parses non-string fields. It used
to build a JSON object of strings and hand it toserde_json, so a struct
field typedf64orboolfailed withinvalid type: string. A form-aware
deserializer now parses primitives out of their string form, maps a repeated
field onto aVec<T>field, and treats an empty value asNonefor an
Option<T>field. Error messages name the field and the expected type. -
BREAKING —
Responseis no longerClone. A streaming body is a
one-shot source of bytes. Nothing in the framework cloned a response
(ctx.resmoves viatake_response()); app code that did must clone the
pieces it needs instead. -
BREAKING —
Response::body_size()returnsOption<usize>. An
open-ended stream has no length; returning0would have silently misled
middleware that gates on size.Body::len_hint()is the same query on the
body itself. -
ctx.stream(body, ..)/Response::stream(body, ..)now stream. The
first argument isimpl Into<Body>; aVec<u8>still works and is sent
buffered exactly as before, aBody::from_stream(..)is sent chunk by
chunk. Previously this helper buffered everything and set
Transfer-Encoding: chunkednext to aContent-Length— a protocol
violation — which it no longer does. -
Response::into_hyperreturnsUnsyncBoxBody<Bytes, Error>rather than
BoxBody: a boxed stream isSendbut notSync, which is all hyper
requires. -
Response::into_hyperis now the single authority on framing headers.
A hand-setTransfer-Encodingis always dropped (with a warning), and a
hand-setContent-Lengthis always dropped, so a stale, duplicated or
contradictory value can never reach the wire: hyper derives the length of a
buffered body, a sized stream supplies its declared size, and an empty body
standing in for a representation (HEAD,304) carries the value given to
the newResponse::advertise_content_length(len). -
BREAKING —
Responsegained a private field. It can no longer be built
as a struct literal; useResponse::new(status)/Response::ok()and the
with_*builders. The three public fields (status,headers,body)
are unchanged. Done now, before 1.0, so later fields are non-breaking. -
Body::len_hint()/Response::body_size()returnNonerather than a
wrapped value when a declared stream size does not fitusize. -
Response::file_*helpers resolve and validate paths withtokio::fs
instead of blockingstd::fscalls inside async code. -
Compression middleware passes streaming bodies through uncompressed; their
bytes do not exist yet when outbound middleware runs. Streaming gzip needs a
flush-per-chunk encoder and is separate work. -
A stream that yields an
Errmid-flight is now logged aterrorlevel.
The status and headers were already sent, so the client sees a truncated
body under the original status; this log is the only server-side trace.
Removed
- BREAKING — untyped
ctx.param()andctx.query(). They returned
Option<&str>, were the first autocomplete hit, and led directly to
ctx.query("page").unwrap_or("1").parse::<i32>().unwrap_or(1). Use the typed
accessors (param_str,query_int,query_as::<T>, the_orvariants).
The raw maps remain public asctx.req.paramsandctx.req.query. - BREAKING — the 16 type-first accessor aliases:
str_query,int_query,
bool_query,str_query_or,int_query_or,bool_query_or,str_param,
int_param,str_param_or,int_param_or,str_body,int_body,
bool_body,str_body_or,int_body_or,bool_body_or. Every one had an
identical source-first spelling (query_str,param_int,body_bool_or, …);
the replacement is the same words in the other order. - BREAKING —
ctx.text(). It was byte-identical toctx.plain(), which is
the Total.js spelling. Usectx.plain(). - BREAKING —
ctx.cancel(). A no-op that returned&Selfand did nothing. - BREAKING —
ctx.csrf()andRequest::csrf().ctx.csrf()wrapped
generate_csrf(None)inunwrap_or_default(), so with no session it returned
""and forms rendered an empty token that failed every POST with no hint.
Request::csrf()returned a fresh random token on each call that was never
stored in a session, so it could never validate. Use
ctx.generate_csrf(None) -> Result<String>. - BREAKING —
ctx.body_form_data_cloned(). Call.clone()on
ctx.body_form_data()instead. - BREAKING —
RequestData,BodyDataandctx.request_data(). The type
had no callers, and its methods were the type-first spellings being removed. - BREAKING — the unused
Responseconstructors:json,html,text,
success,bad_request,unauthorized,conflict,no_content,
not_implemented,not_found_with_message,internal_server_error. None had
a caller outsideresponse.rs's own tests: they are leftovers from the old
Result<Response>handler signature. No user-facing path takes a user-built
Response— handlers returnResult<()>andInboundAction::Stopcarries no
payload — butResponsereaches the prelude, so every dead constructor was
showing up in autocomplete. The constructors the framework itself uses stay
(new,ok,not_found,internal_error,forbidden,not_modified,
redirect*,file_*,binary,stream,sse*,with_body,
with_stream). This also removes theinternal_error/
internal_server_errorpair. Request::body_as_form(), superseded byRequest::body_as_form_data().