Kemal Server-Sent Events (SSE)
This skill provides expert guidance on implementing real-time unidirectional event streaming using Kemal's built-in sse helper (available in Kemal 1.12.0+).
Version Notes
sse route helper and Kemal::EventStream (named events, id:, retry:): since Kemal 1.12.0.
- SSE injection protection (CR/LF rejected in
event/id, CRLF normalization in data and comments) and the retry field Int32 overflow fix: since Kemal 1.13.0.
- Negative
retry spans are omitted from the output (clients discard non-digit values): unreleased, after Kemal 1.13.0.
Core Mandates
SSE Route Definition (Kemal 1.12.0+): Use the sse helper block to define an SSE endpoint:
sse "/events" do |stream, env|
stream.send("hello world")
end
Sending Named Events & Security: Pass event, id, and optional retry (Time::Span) keyword arguments:
sse "/notifications" do |stream, env|
stream.send({"message" => "New alert"}.to_json, event: "alert", id: 101, retry: 5.seconds)
end
Security Note (Kemal 1.13.0+): Kemal::EventStream prevents SSE injection by rejecting raw newlines in event and id arguments, and automatically normalizes CRLF sequences in data and comment.
Streaming Loops & Channels: Use Crystal channels or keep-alive loops for streaming events:
sse "/stream" do |stream, env|
loop do
sleep 1.seconds
stream.send(Time.utc.to_s, event: "time")
rescue IO::Error
break # Connection closed by client
end
end
Patterns from Source Code
Real-Time Live Updates with Channel / Loop
require "kemal"
sse "/live-ticks" do |stream, env|
count = 0_i64
loop do
count += 1
stream.send(
{"count" => count, "timestamp" => Time.utc.to_s}.to_json,
event: "tick",
id: count
)
sleep 1.seconds
rescue IO::Error
# Client disconnected, gracefully terminate streaming loop
break
end
end
Kemal.run
Pub/Sub Broadcasting via SSE Stream Manager
require "kemal"
class SseHub
@streams = Array(Kemal::EventStream).new
@mutex = Mutex.new
def register(stream : Kemal::EventStream)
@mutex.synchronize { @streams << stream }
end
def unregister(stream : Kemal::EventStream)
@mutex.synchronize { @streams.delete(stream) }
end
def broadcast(data : String, event : String? = nil)
@mutex.synchronize do
@streams.reject! do |stream|
begin
stream.send(data, event: event)
false
rescue IO::Error
true # Remove closed connections
end
end
end
end
end
HUB = SseHub.new
sse "/subscribe" do |stream, env|
HUB.register(stream)
# Block while connection remains open
begin
sleep
ensure
HUB.unregister(stream)
end
end
post "/publish" do |env|
message = env.params.body["message"]? || ""
HUB.broadcast(message, event: "news")
env.json({status: "ok"})
end
Kemal.run
Best Practices
- Connection Cleanup: Always handle client disconnects gracefully by rescuing
IO::Error or using an ensure block.
- Content Type: Kemal automatically sets
Content-Type: text/event-stream and Cache-Control: no-cache headers for SSE routes.
- JSON Serialization: Convert Crystal data structures to JSON strings using
.to_json before passing to stream.send(...).
- API Response Helpers: For JSON response formatting and error handling on companion HTTP routes (e.g.
post "/publish"), follow the patterns in kemal-json.
- Prefer SSE over WebSockets for Uni-directional Feeds: Use SSE for live log feeds, dashboard metrics, and notifications where full bi-directional socket protocol overhead is unnecessary.
When to Use
- When building real-time dashboards, log streaming, or live notification feeds.
- When client-to-server responses are handled via standard HTTP POST routes, while server-to-client updates stream via HTTP
text/event-stream.
1---2name: kemal-sse3description: Real-time Server-Sent Events (SSE) streaming in Kemal 1.12+, following established project patterns.4license: MIT5---67# Kemal Server-Sent Events (SSE)89This skill provides expert guidance on implementing real-time unidirectional event streaming using Kemal's built-in `sse` helper (available in Kemal 1.12.0+).1011## Version Notes1213- `sse` route helper and `Kemal::EventStream` (named events, `id:`, `retry:`): since Kemal 1.12.0.14- SSE injection protection (CR/LF rejected in `event`/`id`, CRLF normalization in `data` and comments) and the `retry` field Int32 overflow fix: since Kemal 1.13.0.15- Negative `retry` spans are omitted from the output (clients discard non-digit values): unreleased, after Kemal 1.13.0.1617## Core Mandates1819- **SSE Route Definition (Kemal 1.12.0+):** Use the `sse` helper block to define an SSE endpoint:2021 ```crystal22 sse "/events" do |stream, env|23 stream.send("hello world")24 end25 ```2627- **Sending Named Events & Security:** Pass `event`, `id`, and optional `retry` (`Time::Span`) keyword arguments:2829 ```crystal30 sse "/notifications" do |stream, env|31 stream.send({"message" => "New alert"}.to_json, event: "alert", id: 101, retry: 5.seconds)32 end33 ```3435 *Security Note (Kemal 1.13.0+)*: `Kemal::EventStream` prevents SSE injection by rejecting raw newlines in `event` and `id` arguments, and automatically normalizes CRLF sequences in `data` and `comment`.3637- **Streaming Loops & Channels:** Use Crystal channels or keep-alive loops for streaming events:3839 ```crystal40 sse "/stream" do |stream, env|41 loop do42 sleep 1.seconds43 stream.send(Time.utc.to_s, event: "time")44 rescue IO::Error45 break # Connection closed by client46 end47 end48 ```4950## Patterns from Source Code5152### Real-Time Live Updates with Channel / Loop5354```crystal55require "kemal"5657sse "/live-ticks" do |stream, env|58 count = 0_i6459 loop do60 count += 161 stream.send(62 {"count" => count, "timestamp" => Time.utc.to_s}.to_json,63 event: "tick",64 id: count65 )66 sleep 1.seconds67 rescue IO::Error68 # Client disconnected, gracefully terminate streaming loop69 break70 end71end7273Kemal.run74```7576### Pub/Sub Broadcasting via SSE Stream Manager7778```crystal79require "kemal"8081class SseHub82 @streams = Array(Kemal::EventStream).new83 @mutex = Mutex.new8485 def register(stream : Kemal::EventStream)86 @mutex.synchronize { @streams << stream }87 end8889 def unregister(stream : Kemal::EventStream)90 @mutex.synchronize { @streams.delete(stream) }91 end9293 def broadcast(data : String, event : String? = nil)94 @mutex.synchronize do95 @streams.reject! do |stream|96 begin97 stream.send(data, event: event)98 false99 rescue IO::Error100 true # Remove closed connections101 end102 end103 end104 end105end106107HUB = SseHub.new108109sse "/subscribe" do |stream, env|110 HUB.register(stream)111 # Block while connection remains open112 begin113 sleep114 ensure115 HUB.unregister(stream)116 end117end118119post "/publish" do |env|120 message = env.params.body["message"]? || ""121 HUB.broadcast(message, event: "news")122 env.json({status: "ok"})123end124125Kemal.run126```127128## Best Practices129130- **Connection Cleanup:** Always handle client disconnects gracefully by rescuing `IO::Error` or using an `ensure` block.131- **Content Type:** Kemal automatically sets `Content-Type: text/event-stream` and `Cache-Control: no-cache` headers for SSE routes.132- **JSON Serialization:** Convert Crystal data structures to JSON strings using `.to_json` before passing to `stream.send(...)`.133- **API Response Helpers:** For JSON response formatting and error handling on companion HTTP routes (e.g. `post "/publish"`), follow the patterns in [`kemal-json`](../kemal-json/SKILL.md).134- **Prefer SSE over WebSockets for Uni-directional Feeds:** Use SSE for live log feeds, dashboard metrics, and notifications where full bi-directional socket protocol overhead is unnecessary.135136## When to Use137138- When building real-time dashboards, log streaming, or live notification feeds.139- When client-to-server responses are handled via standard HTTP POST routes, while server-to-client updates stream via HTTP `text/event-stream`.