Autumn's ws feature provides a named channel registry for WebSockets, SSE, and server-rendered htmx fragments. Local development uses in-process tokio::broadcast channels. Multi-replica deployments can switch the same API to Redis pub/sub with autumn.toml.

Enable

TOML
[dependencies]
autumn-web = { version = "0.4", features = ["ws"] }

Publish

Use AppState::broadcast() when the payload is intended for browser clients. publish sends raw UTF-8 text. publish_html wraps a Maud fragment in an hx-swap-oob envelope for htmx.

Rust,no Run
use autumn_web::prelude::*;

#[post("/tasks/{id}/complete")]
async fn complete(state: AppState, Path(id): Path<i64>) -> AutumnResult<&'static str> {
    state.broadcast().publish_html(
        "tasks",
        &html! {
            li id={ "task-" (id) } class="done" { "complete" }
        },
    )?;

    Ok("ok")
}

For protocol payloads that are already encoded, use publish:

Rust,no Run
# use autumn_web::prelude::*;
# fn publish(state: AppState) -> AutumnResult<()> {
state.broadcast().publish("tasks", br#"{"type":"task.completed"}"#.as_slice())?;
# Ok(())
# }

Subscribe with SSE

The one-line SSE primitive subscribes to a topic and emits each message as SSE data.

Rust,no Run
use autumn_web::prelude::*;

#[get("/events")]
async fn events(State(state): State<AppState>) -> impl IntoResponse {
    autumn_web::sse::stream(&state, "tasks")
}

Use stream_authorized when subscription needs access checks. The hook runs before Autumn allocates the channel subscriber.

Rust,no Run
use autumn_web::prelude::*;

#[get("/events/private")]
async fn private_events(
    State(state): State<AppState>,
    session: Session,
) -> AutumnResult<impl IntoResponse> {
    autumn_web::sse::stream_authorized(&state, "private-tasks", |_| async move {
        if session.contains_key("user_id").await {
            Ok(())
        } else {
            Err(AutumnError::unauthorized_msg("login required"))
        }
    })
    .await
}

Direct Channels

AppState::channels() remains the low-level primitive for WebSocket loops and custom transports.

Rust,no Run
# use autumn_web::prelude::*;
# async fn example(state: AppState) -> AutumnResult<()> {
let tx = state.channels().sender("lobby");
let mut rx = state.channels().subscribe_authorized("lobby", |_| async {
    Ok::<(), AutumnError>(())
}).await?;

tx.send("hello")?;
let _ = rx.recv().await;
# Ok(())
# }

Redis Backend

Local is the default:

TOML
[channels]
backend = "in_process"
capacity = 32

Use Redis for multi-replica fan-out:

TOML
[channels]
backend = "redis"
capacity = 128

[channels.redis]
url = "redis://127.0.0.1:6379/"
key_prefix = "autumn:channels"

Equivalent environment overrides:

PowerShell
$env:AUTUMN_CHANNELS__BACKEND = "redis"
$env:AUTUMN_CHANNELS__CAPACITY = "128"
$env:AUTUMN_CHANNELS__REDIS__URL = "redis://127.0.0.1:6379/"
$env:AUTUMN_CHANNELS__REDIS__KEY_PREFIX = "autumn:channels"

The Redis backend publishes locally first, then relays the same envelope over Redis. Each process ignores messages carrying its own origin id, which avoids double-delivery on the publishing replica.

Custom Backends

Implement autumn_web::channels::ChannelsBackend and install it with AppBuilder::with_channels_backend. This bypasses config-driven backend selection, matching the session-store escape hatch.

Rust,no Run
# use autumn_web::prelude::*;
# fn configure(app: autumn_web::app::AppBuilder) -> autumn_web::app::AppBuilder {
app.with_channels_backend(LocalChannelsBackend::new(64))
# }

Actuator Metrics

With the ws feature, /actuator/channels returns per-topic metrics:

Json
{
  "channels": {
    "tasks": {
      "subscriber_count": 2,
      "lifetime_publish_count": 17,
      "dropped_count": 0,
      "lagged_count": 1
    }
  }
}

dropped_count increments when a publish has no active local receivers. lagged_count increments when slow subscribers skip messages from the bounded ring buffer.

Two-Replica Smoke

The runnable examples/ws-echo app includes a Docker Compose smoke that is usable in CI:

Shell
docker compose -f examples/ws-echo/docker-compose.yml up --build --abort-on-container-exit --exit-code-from smoke smoke
docker compose -f examples/ws-echo/docker-compose.yml down -v

The smoke container opens an SSE stream against one replica, publishes through the other, and exits nonzero unless the first replica receives the Maud-rendered list item as an hx-swap-oob fragment.

Manual equivalent while compose is running:

Shell
curl -N http://127.0.0.1:3001/events
curl -X POST http://127.0.0.1:3002/notify

Receiving the /notify list-item fragment on the 3001 stream proves Redis is carrying channel events across replicas. A little ceremony, but at least the abyss is observable.