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— theLast-Event-IDheader 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 viaFrom<EventIdValidationError>and is observable when convertingEventIdValidationError::EmptyintoReplayCursorError.ForbiddenCharacter— the event identifier contained CR, LF, or NULL. This variant can be returned byextract_replay_cursoror by convertingEventIdValidationError::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 theReplayCursorErrorvariant:"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-IDheader:Ok(None) - Empty
Last-Event-IDvalue:Ok(None)(consistent with WHATWG specification treatment of emptyid: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_idandevent_nameare optional.- Omitting
event_namepreserves the browser-defaultmessageevent. datais always emitted and is split into onedata:line per logical line.\r,\n, and\r\nare normalized as logical line breaks.Some("")forevent_nameis rejected withSseFrameError::EmptyEventName.- Event names containing CR, LF, or NULL are rejected with
SseFrameError::InvalidEventName. - NULL is rejected in
datawithSseFrameError::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.
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>.