Skip to content

v1.0.0-rc3

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 23 Oct 12:35
· 7 commits to main since this release

Added

  • Configuration values from the environment or from files. Any string
    value in config.toml or 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_bool and
    AppConfig::section parse 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 validate now loads the effective
    configuration and checks the references of config.prod.toml.
    Templates can read CONF, including referenced values: do not output
    secrets there.

Fixed

  • The session cookie's Secure flag followed the wrong variables. It was
    decided from RUSTF_ENVIRONMENT, RUST_ENV or NODE_ENV — never from
    RUSTF_ENV or [app] environment — so an application configured for
    production could issue non-Secure cookies (any build with
    NODE_ENV=development, or a debug build with RUSTF_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, and RUSTF_ENV=prod made a worker's is_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, secure and http_only, none of which is read:
    session IDs are random and stored server-side (there is no signing secret),
    the timeouts are idle_timeout / absolute_timeout, and cookies are always
    HttpOnly and Secure in production. Changing timeout had no effect. The
    scaffold now writes the real keys; existing projects should rename timeout
    to idle_timeout and 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 Err now answers with the error's own status instead of
    a blanket 500.
    ctx.require_auth()? is a 401, ctx.param_int("id")? on
    /posts/abc is a 400, and the rest of Error::status_code()'s mapping
    (403, 404, 429) applies too. Previously every failing handler flattened
    to 500 Internal Server Error, which made the idiomatic guard unusable and
    left the 401/403/404/429 arms of Error::status_code() as dead code.
    The mapping also covers controller before hooks 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 a 5xx never discloses
    its own message — Error::client_message() returns the error text only for
    Validation, InvalidInput, Authentication and Authorization, and
    everything else (including anything added with with_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
    500 on a route that returns Err needs updating.

  • Error::Timeout maps to 504 Gateway Timeout, not 408 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 a 500. ctx.body_json() /
    Request::body_as_json() reported a parse failure as Error::Internal; it is
    now Error::InvalidInput, the same as a form body that does not deserialize.
    The client reads only Invalid 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 a 400, but its body
    is now Invalid form data instead 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 returned Err or InboundAction::Stop, only the outbound
    middleware it had already passed in priority order ran over the response. An
    auth middleware at priority -100 therefore sent its 401 — and a rate
    limiter at -900 its 429 — without SecurityHeadersMiddleware (priority
    0). Outbound-only middleware further down the list now runs too; dual-phase
    middleware whose inbound phase never ran is still skipped.

  • A SOCKET route on an app with no middleware at all now upgrades. The
    zero-middleware fast path in execute_middleware_chain returned before
    start_pending_upgrade ran, so such an app answered the handshake with 101
    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 an Accept header that ranks a JSON type (application/json
    or any +json suffix, such as application/problem+json) at least as high as
    text/html (q-values respected). When Accept names neither, a classic XHR
    (X-Requested-With: XMLHttpRequest) counts as JSON; htmx does not — even
    with X-Requested-With added 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 from Error::status_code(), body from
    Error::client_message(), as plain text or as
    {"error": ..., "code": ...}. A 5xx reports "code": "E_INTERNAL"
    whichever variant failed.
  • Error::client_message() — the text an error may safely show the client.

Changed

  • RUSTF_ENV is the only environment variable read. RAILS_ENV and
    NODE_ENV (documented) and RUSTF_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. Set RUSTF_ENV or
    [app] environment instead.
  • AppConfig::detect_environment() returns Result<Environment>, and the
    new Environment::parse rejects unknown names. Environment::from_str, which
    maps unknown names to development, is deprecated.
  • From<config::SessionConfig> for the session manager's SessionConfig is
    removed; use SessionConfig::from_app_config(&app_config).
    The session
    section alone cannot tell whether the app runs in production, so the
    conversion set the cookie's Secure flag from a guess. Code that built a
    custom SessionMiddleware with app_config.session.clone().into() now fails
    to compile instead of silently issuing non-Secure cookies in production.
  • RustF::new() and WorkerManager::new() follow RUSTF_ENV (they load
    no configuration file). An invalid value is logged and treated as
    production, the safe side.
  • DEBUG is no longer a Lazy<bool> but a Debug value that reads the
    current environment on every access (*DEBUG and DEBUG.get()), so it
    cannot keep a value computed before the configuration was loaded.
  • routes! no longer accepts SOCKET as a custom HTTP method. The macro's
    catch-all arm used to stringify any identifier, so routes![SOCKET "/x" => h]
    registered a route whose method was literally SOCKET. It now builds a
    WebSocket route: the entry registers as GET, a plain GET gets 426, and
    with --no-default-features (no websocket feature) the invocation does not
    compile. Anything genuinely dispatching a custom SOCKET method must build
    the route with Route::new("SOCKET", path, handler) instead.
  • TrieRouter::add_route takes a RouteInfo instead of five positional
    arguments. Router::add_route(Route) — what applications and controllers
    use — is unchanged.

Added

  • WebSocket support, behind the default-on websocket feature. Total.js
    shaped: routes![SOCKET "/chat/{room}" => chat] registers a GET route that
    only accepts a handshake — a plain GET gets 426 Upgrade Required and the
    handler never runs. ctx.socket(|room| ..) configures the room for the
    matched URL for the first client, with on_open / on_message /
    on_close / on_error closures; 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.) SocketRoom has
    send, send_except, send_to, the _json variants, online, find,
    autodestroy (on by default) and destroy; SocketClient carries the
    handshake's params, query, headers, IP and session. SOCKET::room(path) —
    RustF's F.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 with 1003; [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 malformed routes! now produces a less precise error.
  • ctx.websocket(|socket| ..) — the raw escape hatch, one WebSocket per
    connection with recv / send / send_json / close / split. Control
    frames are handled by the runtime: pings are answered automatically, a close
    frame ends recv, 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 with 1001 when nothing comes back
    between two pings), allowed_origins, accept_subprotocols,
    require_origin (refuse a handshake with no Origin at all — off by
    default, since that would break non-browser clients) and max_connections
    (cap on concurrent sockets; over it, a handshake gets 503).
  • Origin enforcement on the handshake. Same-origin by default, since a
    WebSocket handshake carries cookies and CSRF tokens do not apply to it.
    allowed_origins extends the default rather than replacing it; Origin: null
    is never same-origin; a request with no Origin (a non-browser client) is
    allowed; ["*"] turns the check off.
  • Graceful shutdown closes sockets with 1001 Going Away and 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 a SOCKET route, a room
    with the three handlers, a page, and a dependency-free browser client.
  • Documentation: docs/ABOUT_WEBSOCKET.md and 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 _or variants, for any T: FromStr. These cover the
    types the _int / _bool accessors 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.
  • FormData typed getters: get_as::<T>, get_as_or::<T>, get_all_as::<T>,
    alongside the existing get_str / get_int / get_bool.
  • FormData and FormValue are exported from the crate root and the
    prelude, so a service can take &FormData without reaching into
    rustf::http.
  • Streaming response bodies. Response.body is now a Body enum —
    Body::Full(Vec<u8>) (buffered, the default) or Body::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_body accepts impl Into<Body>, so every existing with_body(vec),
    ctx.json() and ctx.view() call site compiles unchanged.
  • Streamed file responses: ctx.file_download_stream[_from] and
    ctx.file_inline_stream[_from] (and the matching Response:: 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 the text/event-stream wire format with
    Cache-Control: no-cache and X-Accel-Buffering: no. SseEvent builds one
    event (data, id, event, retry, or a comment; SseEvent::json for
    serialised payloads). The stream may yield SseEvent or
    rustf::Result<SseEvent> (sealed trait SseItem), so a fallible source
    can abort the feed with an Err. 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, \n and bare \r into one
    field line each, so an attacker-controlled id, event or data cannot
    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. Range requests are still not supported by the static
    server; streaming changes the memory profile, not the protocol surface.
  • HEAD requests. A HEAD with no matching HEAD route runs the GET
    handler; the response keeps the headers (including the Content-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 a 1xx, 204 or 304.
  • 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 a Content-Length that
    will never be satisfied.
  • Body, BodyStream and SseEvent are exported from the prelude.
  • tokio-stream is now a dependency, so ReceiverStream::new(rx) is the
    idiomatic way to feed an mpsc channel into ctx.sse.

Changed

  • BREAKING — FormData preserves multi-value fields. It wrapped
    HashMap<String, String>, so a repeated field (a checkbox group, tags[])
    silently kept only one value. It now wraps HashMap<String, FormValue>:
    get(key) returns Option<&str> (the first value) and the new
    get_all(key) returns every value. The
    Deref<Target = HashMap<String, String>> impl is gone; use get, get_all,
    get_value, contains_key, len, is_empty, keys, iter or
    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 to serde_json, so a struct
    field typed f64 or bool failed with invalid type: string. A form-aware
    deserializer now parses primitives out of their string form, maps a repeated
    field onto a Vec<T> field, and treats an empty value as None for an
    Option<T> field. Error messages name the field and the expected type.

  • BREAKING — Response is no longer Clone. A streaming body is a
    one-shot source of bytes. Nothing in the framework cloned a response
    (ctx.res moves via take_response()); app code that did must clone the
    pieces it needs instead.

  • BREAKING — Response::body_size() returns Option<usize>. An
    open-ended stream has no length; returning 0 would 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 is impl Into<Body>; a Vec<u8> still works and is sent
    buffered exactly as before, a Body::from_stream(..) is sent chunk by
    chunk. Previously this helper buffered everything and set
    Transfer-Encoding: chunked next to a Content-Length — a protocol
    violation — which it no longer does.

  • Response::into_hyper returns UnsyncBoxBody<Bytes, Error> rather than
    BoxBody: a boxed stream is Send but not Sync, which is all hyper
    requires.

  • Response::into_hyper is now the single authority on framing headers.
    A hand-set Transfer-Encoding is always dropped (with a warning), and a
    hand-set Content-Length is 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 new Response::advertise_content_length(len).

  • BREAKING — Response gained a private field. It can no longer be built
    as a struct literal; use Response::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() return None rather than a
    wrapped value when a declared stream size does not fit usize.

  • Response::file_* helpers resolve and validate paths with tokio::fs
    instead of blocking std::fs calls 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 Err mid-flight is now logged at error level.
    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() and ctx.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 _or variants).
    The raw maps remain public as ctx.req.params and ctx.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 to ctx.plain(), which is
    the Total.js spelling. Use ctx.plain().
  • BREAKING — ctx.cancel(). A no-op that returned &Self and did nothing.
  • BREAKING — ctx.csrf() and Request::csrf(). ctx.csrf() wrapped
    generate_csrf(None) in unwrap_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, BodyData and ctx.request_data(). The type
    had no callers, and its methods were the type-first spellings being removed.
  • BREAKING — the unused Response constructors: json, html, text,
    success, bad_request, unauthorized, conflict, no_content,
    not_implemented, not_found_with_message, internal_server_error. None had
    a caller outside response.rs's own tests: they are leftovers from the old
    Result<Response> handler signature. No user-facing path takes a user-built
    Response — handlers return Result<()> and InboundAction::Stop carries no
    payload — but Response reaches 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 the internal_error /
    internal_server_error pair.
  • Request::body_as_form(), superseded by Request::body_as_form_data().