7.6.7. HV-07 — SSE and Streaming

This tutorial covers Server-Sent Events (SSE) — a standard protocol for server-to-client streaming over HTTP. We build an SSE endpoint on the server, then consume it from the client using request_cb, which delivers the response body incrementally as chunks arrive.

Prerequisites: HV-01 — Simple HTTP Requests and HV-03 — HTTP Server Basics.

7.6.7.1. What is SSE?

SSE (Server-Sent Events) is a one-way streaming protocol built on plain HTTP. The server sends a series of events as text lines:

event: message
data: first

event: message
data: second

event: complete
data: all done

Each event has an event: type and a data: payload, separated by a blank line. The response uses Content-Type: text/event-stream.

Unlike WebSockets, SSE requires no upgrade handshake — it’s just HTTP with a streaming body. This makes it ideal for live feeds, log tailing, and AI API responses (e.g., streaming chat completions).

7.6.7.2. Server-Side SSE Endpoint

Register an SSE endpoint with SSE(). It has the same handler signature as GET or POST — receive (req, resp) and return an http_status. Set the SSE headers and write events to the body:

class SseTutorialServer : HvWebServer {
    def override onInit {
        SSE("/events") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status {
            set_header(resp, "Content-Type", "text/event-stream")
            set_header(resp, "Cache-Control", "no-cache")
            var body = sse_event_string("first", "message")
            body += sse_event_string("second", "message")
            body += sse_event_string("all done", "complete")
            return resp |> TEXT_PLAIN(body)
        }
    }
}

SSE() uses ANY method matching, so both GET and POST requests reach the handler. The helper sse_event_string formats one event:

def sse_event_string(data, event : string) : string {
    return "event: {event}\ndata: {data}\n\n"
}

7.6.7.3. Client-Side Streaming with request_cb

request_cb works like request() but calls a per-chunk callback as body data arrives. This is the building block for consuming SSE streams.

Two overloads are available:

  • String chunks — convenient for text protocols like SSE:

with_http_request() <| $(var req) {
    req.method = http_method.GET
    req.url := "{base_url}/events"
    var chunks : array<string>
    request_cb(req, $(data : string) {
        chunks |> push(clone_string(data))
    }) <| $(var resp) {
        print("status: {int(resp.status_code)}\n")
    }
    for (chunk in chunks) {
        print("chunk: \"{chunk}\"\n")
    }
}
  • Raw bytes — useful for binary data or exact byte counts:

with_http_request() <| $(var req) {
    req.method = http_method.GET
    req.url := "{base_url}/events"
    var total_bytes = 0
    request_cb(req, $(data; var size : int) {
        total_bytes += size
    }) <| $(var resp : HttpResponse?) {
        print("status: {int(resp.status_code)}\n")
    }
    print("received {total_bytes} total bytes\n")
}

Note

request_cb works with any HTTP endpoint — it’s not SSE-specific. The callback fires for every body chunk regardless of Content-Type.

7.6.7.4. POST with Streaming Response

request_cb works with any HTTP method. Here we POST a body and stream the SSE response:

with_http_request() <| $(var req) {
    req.method = http_method.POST
    req.url := "{base_url}/stream-echo"
    req.body := "hello from POST"
    set_content_type(req, "text/plain")
    var chunks : array<string>
    request_cb(req, $(data : string) {
        chunks |> push(clone_string(data))
    }) <| $(var resp) {
        print("status: {int(resp.status_code)}\n")
    }
}

7.6.7.5. Parsing SSE Events

SSE events are line-based. A minimal parser accumulates chunks into a buffer, splits on newlines, and extracts event: and data: fields. A blank line signals the end of one event:

struct SseEvent {
    event : string
    data : string
}

def parse_sse_events(body : string) : array<SseEvent> {
    var events : array<SseEvent>
    var current_event = ""
    var current_data = ""
    peek_data(body) $(bytes) {
        var pos = 0
        let len = length(bytes)
        while (pos < len) {
            var eol = pos
            while (eol < len && int(bytes[eol]) != '\n') {
                eol++
            }
            let line_len = eol - pos
            if (line_len == 0) {
                if (!empty(current_data) || !empty(current_event)) {
                    events |> emplace(SseEvent(event = current_event, data = current_data))
                    current_event = ""
                    current_data = ""
                }
            } else {
                let line = slice(body, pos, eol)
                if (starts_with(line, "event: ")) {
                    current_event = slice(line, 7)
                } elif (starts_with(line, "data: ")) {
                    current_data = slice(line, 6)
                }
            }
            pos = eol + 1
        }
    }
    return <- events
}

Use it with request_cb to parse a streaming SSE response:

var chunks : array<string>
request_cb(req, $(data : string) {
    chunks |> push(clone_string(data))
}) <| $(var resp) {
    assert(resp.status_code == http_status.OK)
}
var full = ""
for (chunk in chunks) {
    full = "{full}{chunk}"
}
var events <- parse_sse_events(full)
for (evt in events) {
    print("[{evt.event}] {evt.data}\n")
}

Output:

[message] first
[message] second
[complete] all done

7.6.7.6. Quick Reference

Function

Description

SSE(uri) <| @(req, resp) { ... }

Server: register SSE endpoint (any HTTP method)

set_header(resp, k, v)

Set a response header (e.g., Content-Type)

resp |> TEXT_PLAIN(body)

Set body and return 200 OK

request_cb(req, on_chunk) <| $(resp) { ... }

Client: streaming request with per-chunk callback

$(data : string) { ... }

String chunk callback overload

$(data; var size : int) { ... }

Raw bytes chunk callback overload

See also

Full source: tutorials/dasHV/07_sse_and_streaming.das

Previous tutorial: HV-06 — WebSockets