Skip to content

Server-Sent Events (SSE)

Server-Sent Events (SSE) enables real-time streaming from HTTP ingresses to clients. When a client requests an SSE stream, the ingress keeps the connection open and pushes events as they occur.

Enabling SSE

SSE mode is automatically enabled when a client sends the Accept: text/event-stream header:

bash
curl -N -H "Accept: text/event-stream" \
  https://example.hantera.io/ingress/api/orders/ORD-123/events

The same HTTP ingress can serve both regular JSON responses and SSE streams based on the Accept header.

Message Format

SSE messages are sent as records with special fields that map to SSE protocol fields:

filtrera
from { 
  event = 'orderUpdated'
  data = { orderId = 'ORD-123', status = 'shipped' }
}

Output:

event: orderUpdated
data: {"orderId":"ORD-123","status":"shipped"}

Standard SSE Fields

FieldPurposeSerialization
eventEvent type for addEventListener()String
dataMain payloadJSON serialized
idEvent ID for reconnection trackingString
retryReconnection delay hint (ms)String

All other fields are serialized as strings and passed through to the client.

Example with All Fields

filtrera
from {
  event = 'heartbeat'
  id = 'hb-42'
  retry = 5000
  data = { timestamp = now }
}

Output:

event: heartbeat
id: hb-42
retry: 5000
data: {"timestamp":"2026-01-11T19:30:00Z"}

Simple Values

Non-record values are automatically wrapped in data::

filtrera
from 'Hello world'

Output:

data: "Hello world"

Real-Time Events with events()

The events() function subscribes to Hantera's event bus, enabling real-time updates:

filtrera
param orderId: text

from events ($'actors/order/{orderId}', 'checkpoint')
  select e => { event = 'updated', data = e }

This subscribes to checkpoint events for a specific order actor and streams them to the client.

Complete Example: SKU Stock Updates

Here's a real-world example streaming real-time stock availability for a SKU:

Ingress Configuration

yaml
uri: /resources/ingresses/api/skus/stock-events
spec:
  type: http
  componentId: sku-stock-events.hreactor
  acl:
    - skus:read
  properties:
    route: api/skus/{skuNumber}/stock
    httpMethod: get

Component Code

filtrera
import 'text'

param skuNumber: text

let safeSkuNumber = skuNumber replace("''", "''''")
let skuQuery = query skus(skuId, skuNumber) 
  filter $'skuNumber == ''{safeSkuNumber}'''

from skuQuery match
  (e: QueryError) |> { error = { code = 'QUERY_ERROR', message = e.message } }
  |>
    let sku = skuQuery first

    from sku match
      nothing |> { error = { code = 'NOT_FOUND', message = 'SKU not found' } }
      |>
        let initialStock = messageActor(
          'sku'
          sku.skuId
          [{ type = 'calculateAvailableStock' }]
        )
        
        from { event = 'init', data = initialStock }
        
        from events ($'actors/sku/{sku.skuId}', 'checkpoint')
          select e => 
            let stock = messageActor(
              'sku'
              sku.skuId
              [{ type = 'calculateAvailableStock' }]
            )
            from { event = 'stockUpdated', data = stock }

This ingress:

  1. Looks up the SKU by its skuNumber
  2. Sends an init event with the current available stock
  3. Streams stockUpdated events whenever the SKU's stock changes

Client-Side Consumption

javascript
const skuNumber = 'PROD-001'
const eventSource = new EventSource(
  `https://api.example.com/ingress/api/skus/${skuNumber}/stock`
)

eventSource.addEventListener('init', (e) => {
  const stock = JSON.parse(e.data)
  console.log('Initial stock:', stock)
  updateStockDisplay(stock)
})

eventSource.addEventListener('stockUpdated', (e) => {
  const stock = JSON.parse(e.data)
  console.log('Stock updated:', stock)
  updateStockDisplay(stock)
})

eventSource.onerror = (e) => {
  console.log('Connection error, auto-reconnecting...')
}

// Clean up when done
function cleanup() {
  eventSource.close()
}

Multiple Streams

You can combine multiple event sources and immediate values:

filtrera
param orderId: uuid
param paymentId: uuid

from { event = 'init', data = { orderId, paymentId } }

from events ($'actors/order/{orderId}', 'checkpoint')
  select e => { event = 'orderUpdated', data = e }

from events ($'actors/payment/{paymentId}', 'checkpoint')
  select e => { event = 'paymentUpdated', data = e }

Multiple iterators are merged and events are delivered as they arrive from any source.

Error Handling

Pre-Stream Errors

Errors returned before streaming starts result in standard HTTP errors:

filtrera
import 'text'

param skuNumber: text

let safeSkuNumber = skuNumber replace("''", "''''")
let skuQuery = query skus(skuId) filter $'skuNumber == ''{safeSkuNumber}'''

from skuQuery match
  (e: QueryError) |> { error = { code = 'QUERY_ERROR', message = e.message } }
  |>
    let sku = skuQuery first
    from sku match
      nothing |> { error = { code = 'NOT_FOUND', message = 'SKU not found' } }
      |> { event = 'init', data = sku }

If the SKU doesn't exist, the client receives:

http
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":{"code":"NOT_FOUND","message":"SKU not found"}}

The EventSource.onerror handler will fire for HTTP error responses.

Application-Level Errors

For errors during streaming, send an error event:

filtrera
from events (topic, eventType)
  select e => e match
    { error: text } |> { event = 'error', data = { code = 'STREAM_ERROR', message = e.error } }
    |> { event = 'updated', data = e }

Client handling:

javascript
eventSource.addEventListener('error', (e) => {
  const error = JSON.parse(e.data)
  console.error('Application error:', error)
})

Connection Errors

Connection drops trigger the onerror handler and automatic reconnection:

javascript
eventSource.onerror = (e) => {
  if (eventSource.readyState === EventSource.CONNECTING) {
    console.log('Reconnecting...')
  } else if (eventSource.readyState === EventSource.CLOSED) {
    console.log('Connection closed')
  }
}

Keepalive

Hantera sends keepalive comments every 30 seconds to maintain the connection:

:keepalive

These are automatically ignored by EventSource clients but prevent proxy timeouts.

Reconnection Behavior

The browser's EventSource API automatically reconnects on connection loss:

  1. Connection drops
  2. Browser waits (default 3000ms, or value from retry field)
  3. Browser reconnects with Last-Event-ID header if IDs were sent

On reconnection, the component is re-executed. Design your component to handle this by always sending initial state:

filtrera
from { event = 'init', data = currentState }

from events (topic, eventType)
  select e => { event = 'updated', data = e }

Testing with cURL

bash
curl -N -H "Accept: text/event-stream" \
  https://example.hantera.io/ingress/api/skus/PROD-001/stock

The -N flag disables buffering for immediate output.

See Also

© 2024 Hantera AB. All rights reserved.