On this page

Server Sent Events

Server-sent events send a stream of text updates from the Arduino to a browser. Use a normal HTTP endpoint for commands sent in the other direction, or use WebSockets for bidirectional messages. Install aWOT 4.0.0 through Arduino Library Manager. This complete ESP32 sketch uses the explicit <SseChannel.h> header and serves a page showing uptime and a named, multiline event from an updates room. Set your WiFi credentials and open the address printed at 115200 baud.

#include <WiFi.h>
#include <aWOT.h>
#include <SseChannel.h>

WiFiServer server(80);
App app;
SseChannel<WiFiClient, 4, 1> events;
unsigned long lastPush = 0;

P(page) =
    "<!doctype html><meta charset='utf-8'><title>aWOT events</title>"
    "<p id='status'>Connecting...</p><p>Uptime: <span id='uptime'></span></p>"
    "<pre id='details'></pre><script>"
    "const source=new EventSource('/events');"
    "source.onopen=()=>document.getElementById('status').textContent='Connected';"
    "source.onerror=()=>document.getElementById('status').textContent="
    "'Connection interrupted; browser will retry';"
    "source.onmessage=e=>document.getElementById('uptime').textContent="
    "e.data+' seconds';"
    "source.addEventListener('sample',e=>"
    "document.getElementById('details').textContent=e.data);"
    "window.addEventListener('pagehide',()=>source.close());"
    "</script>";

void indexHandler(Request& req, Response& res) {
  res.set("Content-Type", "text/html; charset=utf-8");
  res.printP(page);
}

void eventsHandler(Request& req, Response& res) {
  events.open(res);
}

void onConnect(int clientId) {
  events.join(clientId, "updates");
  events.send(clientId, "sample", "Connected to updates");
  Serial.println("Event stream added");
}

void setup() {
  Serial.begin(115200);
  WiFi.begin("YOUR_SSID", "YOUR_PASSWORD");
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println(WiFi.localIP());

  if (events.defineRoom("updates") < 0) {
    Serial.println("No room capacity");
    while (true) delay(1000);
  }
  events.onConnect(onConnect);
  app.get("/", indexHandler);
  app.get("/events", eventsHandler);
  server.begin();
}

void loop() {
  WiFiClient client = server.available();
  if (client.connected()) {
    App::ProcessResult result = app.process(&client);
    if (result.responseOpen) {
      int clientId = events.add(client);
      if (clientId < 0) Serial.println("No event-stream capacity");
    } else {
      client.stop();
    }
  }

  unsigned long now = millis();
  if (now - lastPush >= 1000) {
    lastPush = now;
    char uptime[16];
    snprintf(uptime, sizeof(uptime), "%lu", now / 1000);
    events.to("updates", uptime);
    events.to("updates", "sample", "Arduino is online\nUpdated once per second");
  }
}

Opening and retaining the connection

Call events.open(res) before sending headers. It selects status 200, text/event-stream, and Cache-Control: no-cache, then calls keepOpen(). The HTTP body is close-delimited, without Content-Length or chunk framing. App::process() sends the headers and returns responseOpen == true after successful processing. Only then does the sketch call events.add(client).

add() copies the concrete client handle into one of the four slots, calls onConnect(int clientId), and returns the slot index. The callback joins the updates room and sends an initial event only to that client. The copy shares the original connection, so leave the original handle unstopped after handing it to the channel. If every slot is connected, add() stops the new client and returns -1. Disconnected slots are reusable and begin with no memberships. Use a channel capacity that fits your transport’s socket count and board RAM. Keep the channel alive while its connections are active.

Match the channel’s client type to the accepted transport: this sketch uses WiFiClient throughout. Do not add the temporary adapter from process(Stream*); it and the HTTP Request/Response objects are temporary. For applications with several open-response routes, track which route opened the response, for example through request context, before assigning it to an SSE channel. This sketch has one such route.

Events and browser handling

send(clientId, data) targets one active client, to("updates", data) targets active members of that room, and broadcast(data) targets all active clients. Each family also accepts an event name before the data: for example, to("updates", "sample", data) sends a named event to the room, while broadcast("sample", data) sends it to everyone. Unnamed events reach EventSource.onmessage; named events reach addEventListener(event, ...). Data must be a non-null NUL-terminated string, and event names must contain no CR or LF. Sends happen immediately and do not retain these strings. Send UTF-8 text; binary bytes or embedded NUL need an application encoding.

Define a room before using its name. defineRoom() returns its index, reuses the index for equal, case-sensitive name contents even at capacity, or returns -1 for a null name or a full table. Empty names are valid. Names are borrowed; use string literals or storage that remains alive and unchanged while the channel exists. findRoom(name) returns an index or -1 without creating a room. Null or unknown names make targeting and membership changes no-ops and make inRoom() return false.

join(clientId, room), leave(clientId, room), and inRoom(clientId, room) accept a name or numeric index, as does to(). Save the index returned by defineRoom() or findRoom() to avoid repeated name lookups; for example, events.to(roomIdx, uptime) uses the same room. Numeric indices in 0..MAX_ROOMS-1 also work without registered names. A client can join several rooms, and each channel keeps its own memberships. MAX_ROOMS defaults to eight and must be in 1..32; this sketch reserves one room.

The multiline sample is sent as two data: lines followed by a blank line. The channel normalizes CR, LF, and CRLF, preserving empty and trailing lines. The browser joins the lines into the event’s data string. Use textContent when displaying received text, as the example does. These event types and text encoding follow the EventSource standard.

An equivalent browser client for a page already served by the device is:

const events = new EventSource('/events');
events.onmessage = (event) => console.log('Uptime:', event.data);
events.addEventListener('sample', (event) => console.log(event.data));
// Call events.close() when the page no longer needs updates.

Disconnects, heartbeat, and replay

There is no SSE poll() method. send(), to(), and broadcast() detect disconnected clients and write failures among their targets, stop the affected handles, and free their slots. They retry positive short writes but return no delivery result. forEach(callback) also removes disconnected clients and exposes each remaining client’s Print interface, which you can use for custom SSE comments or fields. Direct writes through that interface need their own return-count/error handling. The channel has an onConnect(int clientId) callback and no disconnect callback.

count() counts active slots and connected(clientId) reports whether a slot is active; neither checks the underlying transport. Sending, forEach(), and slot reuse update that state as they discover disconnected clients. Use close(clientId) to stop a client immediately and clear its memberships. Slot IDs can be reused, so they are not permanent identities.

The once-per-second uptime is application traffic. The channel has no automatic heartbeat and does not send periodic comments by itself. If an application is otherwise silent, schedule your own comment or event writes and cleanup.

EventSource reconnects after an interrupted stream, but this sketch resumes with the current uptime. The channel does not generate event IDs, retain an event history, or replay missed updates. Reliable replay requires application logic: capture Last-Event-ID in a Request::Header array, retain a bounded history, and emit appropriate id: fields. See the SseChannel reference for all public methods.