runtime: native SSE streaming — http_sse_open/send/close

Add Server-Sent Events support to the El runtime. El v2 handlers can now
hold HTTP connections open and push events in real time.

New builtins in el_seed.c:
  __http_conn_fd()          — retrieve raw fd from thread-local set by worker
  __http_sse_open(fd)       — send SSE headers (text/event-stream), keep-alive
  __http_sse_send(fd, data) — write "data: <data>\n\n" frame
  __http_sse_close(fd)      — close the connection fd

http_worker_v2 in legacy/el_runtime.c now:
  - stashes the fd via el_seed_set_http_conn_fd() before calling the handler
  - detects the "__sse__" sentinel return value to skip http_send_response
    and skip close(fd) — SSE handler took ownership of the fd
  - clears the thread-local after the handler returns

El wrappers added to runtime/http.el:
  http_conn_fd() http_sse_open(fd) http_sse_send(fd, data)
  http_sse_close(fd) http_sse_sentinel()
This commit is contained in:
Will Anderson
2026-05-03 17:15:37 -05:00
parent 3e5130e98d
commit 4ae42ee7db
5 changed files with 184 additions and 9 deletions
+88
View File
@@ -554,6 +554,94 @@ el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body)
return http_response(status, headers_json, body);
}
/* ── HTTP SSE — Server-Sent Events streaming ─────────────────────────────── */
/*
* Thread-local file descriptor stashed by http_worker_v2 before calling the
* El handler. El SSE builtins read this to get the raw socket fd.
*
* Lifecycle:
* http_worker_v2 sets _tl_http_conn_fd = fd (via el_seed_set_http_conn_fd)
* El handler calls __http_conn_fd() → receives that fd
* El handler calls __http_sse_open(fd) → sends SSE headers, keeps fd open
* El handler calls __http_sse_send(fd, data) → writes "data: ...\n\n"
* El handler calls __http_sse_close(fd) → closes the fd
* El handler returns "__sse__" sentinel → http_worker_v2 does NOT close fd
*
* The -1 value means no current connection (guard against misuse outside
* a handler context).
*/
static __thread int _tl_http_conn_fd = -1;
/* Called by el_runtime.c's http_worker_v2 — not part of the El ABI. */
void el_seed_set_http_conn_fd(int fd) {
_tl_http_conn_fd = fd;
}
/* __http_conn_fd() — returns the raw fd for the current HTTP connection.
* Valid only inside an http_serve_v2 handler before it returns. */
el_val_t __http_conn_fd(void) {
return EL_INT(_tl_http_conn_fd);
}
/* __http_sse_open(fd) — sends SSE response headers on fd, keeping it open.
* Returns 1 on success, 0 on write failure. */
el_val_t __http_sse_open(el_val_t conn_id) {
int fd = (int)(int64_t)conn_id;
if (fd < 0) return 0;
static const char sse_headers[] =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Cache-Control: no-cache\r\n"
"Connection: keep-alive\r\n"
"Access-Control-Allow-Origin: *\r\n"
"\r\n";
size_t n = sizeof(sse_headers) - 1; /* exclude NUL */
size_t sent = 0;
while (sent < n) {
ssize_t w = write(fd, sse_headers + sent, n - sent);
if (w <= 0) return 0;
sent += (size_t)w;
}
return 1;
}
/* __http_sse_send(fd, data) — writes one SSE event frame: "data: <data>\n\n".
* data must not contain newlines. Returns 1 on success, 0 on client disconnect. */
el_val_t __http_sse_send(el_val_t conn_id, el_val_t data) {
int fd = (int)(int64_t)conn_id;
if (fd < 0) return 0;
const char* s = EL_CSTR(data);
if (!s) s = "";
/* Build "data: <s>\n\n" in a single buffer for one write call. */
size_t prefix_len = 6; /* "data: " */
size_t slen = strlen(s);
size_t total = prefix_len + slen + 2; /* + "\n\n" */
char* buf = malloc(total + 1);
if (!buf) return 0;
memcpy(buf, "data: ", 6);
memcpy(buf + 6, s, slen);
buf[6 + slen] = '\n';
buf[6 + slen + 1] = '\n';
buf[total] = '\0';
size_t sent = 0;
int ok = 1;
while (sent < total) {
ssize_t w = write(fd, buf + sent, total - sent);
if (w <= 0) { ok = 0; break; }
sent += (size_t)w;
}
free(buf);
return ok ? 1 : 0;
}
/* __http_sse_close(fd) — closes the SSE connection fd. */
el_val_t __http_sse_close(el_val_t conn_id) {
int fd = (int)(int64_t)conn_id;
if (fd < 0) return 0;
close(fd);
return 1;
}
/* ── Threading ───────────────────────────────────────────────────────────── */
/*
* Design:
+20
View File
@@ -81,6 +81,26 @@ void __http_serve_v2(el_val_t port, el_val_t handler_name);
* headers_json: JSON object literal like {"Content-Type":"text/plain"} or "{}" */
el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body);
/* ── HTTP SSE — Server-Sent Events streaming ─────────────────────────────── */
/* Returns the raw file descriptor for the current HTTP connection.
* Valid only inside an http_serve_v2 handler before it returns.
* Returns -1 if called outside a handler context. */
el_val_t __http_conn_fd(void);
/* Sends SSE response headers on conn_id (the fd from __http_conn_fd),
* keeping the connection open for streaming. Returns 1 on success, 0 on
* write failure. Call once at the start of an SSE handler. */
el_val_t __http_sse_open(el_val_t conn_id);
/* Writes one SSE event frame: "data: <data>\n\n". data must not contain
* newlines. Returns 1 on success, 0 if the client disconnected. */
el_val_t __http_sse_send(el_val_t conn_id, el_val_t data);
/* Closes the SSE connection. The handler must return http_sse_sentinel()
* so the HTTP worker does not double-close the fd. */
el_val_t __http_sse_close(el_val_t conn_id);
/* ── Threading ───────────────────────────────────────────────────────────── */
/* Create a thread that calls the named El function with a String argument.
+21 -9
View File
@@ -1673,6 +1673,7 @@ static void* http_worker_v2(void* arg) {
HttpWorkerArg* a = (HttpWorkerArg*)arg;
int fd = a->fd;
free(a);
int is_sse = 0;
char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL;
if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) {
http_handler4_fn h = http_lookup_active_v2();
@@ -1680,28 +1681,39 @@ static void* http_worker_v2(void* arg) {
int head_only = (method && strcmp(method, "HEAD") == 0);
const char* dispatch_method = head_only ? "GET" : method;
el_request_start(); /* begin per-request arena */
/* Expose the raw fd to El SSE builtins (__http_conn_fd etc.). */
el_seed_set_http_conn_fd(fd);
if (h) {
el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body));
const char* rs = EL_CSTR(r);
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
/* Detect SSE sentinel — handler took ownership of the fd. */
if (rs && strcmp(rs, "__sse__") == 0) {
is_sse = 1;
} else {
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
}
el_release(hmap);
} else {
response = el_strdup_persist(
"el-runtime: no v2 http handler registered "
"(call http_set_handler_v2)");
}
el_seed_set_http_conn_fd(-1); /* clear before arena teardown */
el_request_end(); /* free all intermediate strings */
_tl_http_head_only = head_only;
http_send_response(fd, response);
_tl_http_head_only = 0;
free(response);
if (!is_sse) {
_tl_http_head_only = head_only;
http_send_response(fd, response);
_tl_http_head_only = 0;
free(response);
}
}
free(method); free(path); free(body); free(hdr_block);
close(fd);
/* SSE handlers close the fd themselves via __http_sse_close. */
if (!is_sse) close(fd);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
+5
View File
@@ -176,6 +176,11 @@ void http_set_handler_v2(el_val_t name);
* auto-content-type contract for legacy handlers that return plain bodies. */
el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body);
/* SSE connection fd — set by http_worker_v2 before calling the El handler,
* cleared afterwards. Defined in el_seed.c; called from el_runtime.c.
* The getter is exposed as __http_conn_fd() to El programs. */
void el_seed_set_http_conn_fd(int fd);
/* HTTP timeout — every libcurl request honors EL_HTTP_TIMEOUT_MS (default
* 60000ms). Read lazily on first use, so setting the env var any time before
* the first http_* call is sufficient. */
+50
View File
@@ -169,3 +169,53 @@ fn http_serve_v2(port: Int, handler: String) {
fn http_response(status: Int, headers_json: String, body: String) -> String {
return __http_response(status, headers_json, body)
}
// SSE Server-Sent Events streaming
//
// Usage pattern for an SSE handler:
//
// fn my_handler(method: String, path: String, headers: Map<String, String>, body: String) -> String {
// let fd: Int = http_conn_fd()
// http_sse_open(fd)
// http_sse_send(fd, "hello")
// http_sse_send(fd, "world")
// http_sse_close(fd)
// return http_sse_sentinel()
// }
//
// The sentinel return value tells http_serve_v2 NOT to close the connection
// automatically the handler already closed it via http_sse_close.
// http_conn_fd returns the raw file descriptor for the current HTTP connection.
// Only valid inside an http_serve_v2 handler, before the handler returns.
// Use with http_sse_open / http_sse_send / http_sse_close for streaming.
fn http_conn_fd() -> Int {
return __http_conn_fd()
}
// http_sse_open sends SSE response headers on the current connection,
// keeping it open for streaming. Call once at the start of an SSE handler.
// Returns true on success.
fn http_sse_open(fd: Int) -> Bool {
return __http_sse_open(fd)
}
// http_sse_send writes one SSE event to the connection.
// data should not contain newlines (they are added automatically).
// Returns true if the write succeeded (client still connected).
fn http_sse_send(fd: Int, data: String) -> Bool {
return __http_sse_send(fd, data)
}
// http_sse_close closes the SSE connection.
fn http_sse_close(fd: Int) {
__http_sse_close(fd)
return
}
// http_sse_sentinel is the return value an SSE handler must return
// to tell the HTTP server NOT to close the connection automatically.
// The handler takes ownership of the fd and closes it via http_sse_close.
fn http_sse_sentinel() -> String {
return "__sse__"
}