Server-Sent Events (SSE) helpers

Updated May 19, 2026

The sse module provides wire-level helpers for implementing Server-Sent Events endpoints without imposing opinions on event store design or identifier generation strategies.

Event identifiers (`EventId`)

The EventId type wraps a validated string that is safe to emit in an SSE id: line. Construction rejects three characters that would corrupt the SSE wire format per the Web Hypertext Application Technology Working Group (WHATWG) HTML specification § 9.2.6:

  • Carriage return (U+000D)
  • Line feed (U+000A)
  • NULL (U+0000)

All other characters, including non-ASCII Unicode and whitespace, are accepted. The identifier is treated as an opaque string with no trimming or format requirements (UUID, integer, composite key, etc.).

Usage

use actix_v2a::EventId;

// Construct a validated identifier
let id = EventId::new("evt-001")?;

// Access the identifier string
assert_eq!(id.as_str(), "evt-001");

// Convert to String if needed
let id_string: String = id.into();

Error handling

Construction returns EventIdValidationError:

  • Empty — the identifier value was empty.
  • ForbiddenCharacter — the identifier contained CR, LF, or NULL.

Replay cursors (`ReplayCursor`)

The ReplayCursor type wraps a validated EventId to distinguish between "an identifier to attach to an outgoing SSE frame" and "an identifier received from a client's reconnection header". The inner EventId carries the same validation guarantees.

Extracting the Last-Event-ID header

Use extract_actix_replay_cursor to parse and validate the Last-Event-ID request header in Actix Web handlers. The extract_replay_cursor function is the framework-agnostic domain function and takes &[SseHeader].

use actix_v2a::extract_actix_replay_cursor;
use actix_web::HttpRequest;

fn handle_sse_request(req: HttpRequest) -> Result<(), Error> {
    let replay_cursor = extract_actix_replay_cursor(req.headers())?;

    if let Some(cursor) = replay_cursor {
        // Client is reconnecting; start replay from cursor.event_id()
        let last_id = cursor.event_id();
        // ... resume stream from last_id
    } else {
        // New connection; start from beginning or latest event
        // ...
    }

    Ok(())
}

Replay cursor error handling

extract_replay_cursor and From<EventIdValidationError> can both produce ReplayCursorError, but they do not expose the same variants:

  • InvalidHeader — the Last-Event-ID header was malformed because it was duplicated or, at the Actix adapter boundary, contained a non-UTF-8 value.
  • ReplayCursorError::Empty — the event identifier value was empty. This variant exists for API completeness via From<EventIdValidationError> and is observable when converting EventIdValidationError::Empty into ReplayCursorError.
  • ForbiddenCharacter — the event identifier contained CR, LF, or NULL. This variant can be returned by extract_replay_cursor or by converting EventIdValidationError::ForbiddenCharacter.

extract_replay_cursor never returns ReplayCursorError::Empty. An empty Last-Event-ID header is treated as Ok(None) per the WHATWG specification's reset semantics, so callers only see ReplayCursorError::Empty after an explicit From<EventIdValidationError> conversion.

use actix_v2a::{EventIdValidationError, ReplayCursorError, SseHeader, extract_replay_cursor};

fn classify(headers: &[SseHeader]) -> Result<&'static str, ReplayCursorError> {
    match extract_replay_cursor(headers) {
        Ok(Some(_)) => Ok("resume"),
        Ok(None) => Ok("start"),
        Err(ReplayCursorError::InvalidHeader) => Ok("reject malformed header"),
        Err(ReplayCursorError::ForbiddenCharacter) => Ok("reject unsafe identifier"),
        Err(ReplayCursorError::Empty) => unreachable!(
            "extract_replay_cursor returns Ok(None) for empty Last-Event-ID headers"
        ),
    }
}

fn classify_converted(error: EventIdValidationError) -> &'static str {
    match ReplayCursorError::from(error) {
        ReplayCursorError::Empty => "reject empty converted identifier",
        ReplayCursorError::ForbiddenCharacter => "reject unsafe identifier",
        ReplayCursorError::InvalidHeader => unreachable!(
            "From<EventIdValidationError> never produces InvalidHeader"
        ),
    }
}

Operator observability

extract_actix_replay_cursor emits a tracing::error! event whenever header extraction fails at the Actix adapter boundary. Operators can observe these events with any tracing subscriber, such as tracing-subscriber with EnvFilter.

Each error event carries two structured fields:

  • header_name — always "Last-Event-ID".
  • error_variant — the name of the ReplayCursorError variant: "InvalidHeader", "Empty", or "ForbiddenCharacter".

To capture these events, configure a subscriber that accepts ERROR-level events from the crate:

RUST_LOG=actix_v2a=error cargo run

Example event fields emitted for a duplicate Last-Event-ID header:

ERROR actix_v2a::sse::replay_cursor: replay cursor header extraction failed
  header_name="Last-Event-ID"
  error_variant="InvalidHeader"

The message text differs by call site:

  • "replay cursor header extraction failed" — duplicate or forbidden-character header values.
  • "replay cursor header is invalid UTF-8" — non-UTF-8 bytes in the Actix header value.

The framework-agnostic extract_replay_cursor function remains a pure query and returns typed errors without emitting tracing events itself.

Header extraction behaviour

  • Missing Last-Event-ID header: Ok(None)
  • Empty Last-Event-ID value: Ok(None) (consistent with WHATWG specification treatment of empty id: fields as a reset)
  • Valid non-empty value: Ok(Some(ReplayCursor))
  • Duplicate headers: Err(ReplayCursorError::InvalidHeader)
  • Non-UTF-8 value: Err(ReplayCursorError::InvalidHeader)
  • Forbidden characters (CR, LF, NULL): Err(ReplayCursorError::ForbiddenCharacter)

Error mapping

Use map_replay_cursor_error to convert validation failures to the shared API error envelope:

use actix_v2a::{extract_actix_replay_cursor, map_replay_cursor_error};
use actix_web::HttpRequest;

fn handle_sse_request(req: HttpRequest) -> Result<(), actix_v2a::Error> {
    let replay_cursor = extract_actix_replay_cursor(req.headers())
        .map_err(|e| map_replay_cursor_error(&e))?;

    // ... use replay_cursor
    Ok(())
}

All validation errors map to ErrorCode::InvalidRequest with descriptive messages suitable for client responses.

Header constant

The LAST_EVENT_ID_HEADER constant provides the standardized header name:

use actix_v2a::{LAST_EVENT_ID_HEADER, SseHeader, extract_replay_cursor};

let headers = vec![SseHeader::new(LAST_EVENT_ID_HEADER, "evt-001")];
let cursor = extract_replay_cursor(&headers)
    .expect("valid header")
    .expect("cursor present");
assert_eq!(cursor.as_ref(), "evt-001");

Frame rendering

Use render_event_frame to emit complete SSE event frames terminated by a blank line:

use actix_v2a::{EventId, render_event_frame};

let id = EventId::new("evt-001")?;
let frame = render_event_frame(
    Some(&id),
    Some("message_created"),
    "first line\nsecond line",
)?;

assert_eq!(
    frame,
    "id: evt-001\nevent: message_created\ndata: first line\ndata: second line\n\n"
);

Rendering rules:

  • event_id and event_name are optional.
  • Omitting event_name preserves the browser-default message event.
  • data is always emitted and is split into one data: line per logical line.
  • \r, \n, and \r\n are normalized as logical line breaks.
  • Some("") for event_name is rejected with SseFrameError::EmptyEventName.
  • Event names containing CR, LF, or NULL are rejected with SseFrameError::InvalidEventName.
  • NULL is rejected in data with SseFrameError::InvalidData.

Use render_comment_frame for heartbeat traffic or other comment frames:

use actix_v2a::render_comment_frame;

let heartbeat = render_comment_frame("")?;
assert_eq!(heartbeat, ":\n\n");

Comment rendering also normalizes \r, \n, and \r\n into logical line breaks and rejects NULL with SseFrameError::InvalidComment.

Shared heartbeat helper

Use HeartbeatPolicy when an endpoint needs the shared heartbeat cadence in typed form:

use std::time::Duration;
use actix_v2a::{DEFAULT_HEARTBEAT_INTERVAL, HeartbeatPolicy};

let default_policy = HeartbeatPolicy::default();
assert_eq!(default_policy.interval(), DEFAULT_HEARTBEAT_INTERVAL);

let custom_policy = HeartbeatPolicy::new(Duration::from_secs(5))?;
assert_eq!(custom_policy.interval(), Duration::from_secs(5));

The default interval is 20 seconds. HeartbeatPolicy::new rejects Duration::ZERO, so applications must make an explicit choice if they want to disable heartbeat scheduling entirely.

Use render_heartbeat_frame to emit the canonical heartbeat wire frame:

use actix_v2a::render_heartbeat_frame;

let frame = render_heartbeat_frame()?;
assert_eq!(frame, ":\n\n");

This crate still does not schedule heartbeats. Applications remain responsible for timer ownership, background tasks, and stream lifecycle.

Shared `stream_reset` helper

Use render_stream_reset_frame when replay cannot resume from the supplied cursor:

use actix_v2a::{
    STREAM_RESET_EVENT_NAME,
    STREAM_RESET_REPLAY_UNAVAILABLE_PAYLOAD,
    render_stream_reset_frame,
};

let frame = render_stream_reset_frame()?;

assert_eq!(STREAM_RESET_EVENT_NAME, "stream_reset");
assert_eq!(
    STREAM_RESET_REPLAY_UNAVAILABLE_PAYLOAD,
    "{\"reason\":\"replay_unavailable\"}"
);
assert_eq!(
    frame,
    "event: stream_reset\ndata: {\"reason\":\"replay_unavailable\"}\n\n"
);

The helper is fixed to the shared control event and does not expose a generic application-event builder.

Live-stream cache headers

Use apply_actix_event_stream_cache_control to set the canonical anti-reuse policy for a live event stream in Actix Web responses.

use actix_v2a::{EVENT_STREAM_CACHE_CONTROL, CACHE_CONTROL_HEADER};
use actix_v2a::apply_actix_event_stream_cache_control;
use actix_web::http::header::HeaderMap;

let mut headers = HeaderMap::new();
apply_actix_event_stream_cache_control(&mut headers);

let value = headers
    .get(CACHE_CONTROL_HEADER)
    .expect("cache header should be present")
    .to_str()
    .expect("header should be valid UTF-8");
assert_eq!(value, EVENT_STREAM_CACHE_CONTROL);

The apply_actix_event_stream_cache_control function is the Actix-specific adapter. The apply_event_stream_cache_control function is the framework-agnostic domain function that takes &mut Vec<SseHeader>.