Chapter Orientation

For one-way push from server to client, the Web world has two options: WebSocket (Chapters 95–97, bidirectional, a separate protocol) and SSE (Server-Sent Events — an event protocol on an HTTP response stream). xhttp's implementation philosophy in one sentence: no dedicated SSE connection, thread, queue, or network state machine — xrtHttpSseReplyCreate creates an ordinary xhttpreply with status 200, Content-Type: text/event-stream, and a one-shot body of unknown length; the actual sending is driven by the HTTP Server's one and only response state machine, TCP/TLS backpressure, and the write deadline; the returned xhttpbodystream is an independent producer end, handed to a background task or any thread to write. The client side is the same mirror: a connection layer plus an event parser (the incremental syntax machine for text/event-stream), with Last-Event-ID disconnect resume built in. The selection rule: whenever push is one-way, use SSE — the protocol is simple, it rides port 80, and automatic reconnection is built into browsers.

107.1引入

Progress push, market-data streams, log tailing, token-by-token output from AI generation — "the server keeps talking, the client only listens" covers most push needs. Bringing WebSocket to such needs is anti-aircraft guns for mosquitoes: one-way use of a bidirectional protocol, plus the full burden of handshake upgrade, heartbeats, and the close protocol. SSE's answer is "don't switch protocols": still an HTTP response, just one that never ends — on a chunked stream with Content-Type: text/event-stream, lines of event:/data:/id:/retry: fields compose events, separated by blank lines.

The engineering difficulty sits on both ends. Server: the response stream must cooperate with background producers — events come from a task thread, the connection writes on a Worker, so how does backpressure travel? Client: the event syntax is incremental (one data field may be split across multiple data: lines; events span chunk boundaries) — the parser must be streaming. xhttp gives structured answers on both ends: server-side SseSendEvent (one complete event per call, validate before encoding, never commit half an event on failure) + the xhttpbodystream producer end (AGAIN backpressure + WaitWritable recovery); the client-side parser advances field by field under cap constraints.

107.2概念

Server: riding the one and only state machine

1CreateSseReplyCreate(config, &stream) -> an ordinary Reply (200/text-event-stream/body of unknown length)
2Customizebefore commit, use ordinary Header APIs to add Cache-Control/CORS/X-Accel-Buffering (deployment-layer duties)
3Commitafter HttpConnRespond the Server freezes and retains the body source - the Reply may be destroyed
4Producehand the stream to a task thread -> SseSendEvent/SendComment/Write family writes
5BackpressureAGAIN retains no input reference -> wait on WaitWritable and retry
6Finishlast-reference destruction = queued events then a normal EOF; Close is an idempotent pre-close; Fail discards undelivered + a stable Cause

Three design disciplines. (1) The boundary between protocol-required and application-decided: ReplyCreate sets only Content-Type; Cache-Control: no-cache, authentication, CORS, and proxy buffering policy (X-Accel-Buffering: no) are set by the application before commit with ordinary Header APIs — the SSE layer does not guess your deployment environment. (2) Atomicity: every Send fully validates and meters first, then encodes directly into one Body Stream node — no temporary event string is built, no half event committed on failure; byte and Chunk hard budgets cover concurrent reservations, queuing, and active leases. (3) Lifecycle decoupling: after Respond commits, the Reply may be destroyed while the Stream lives on — the "response object" and the "producer end" are two lifetimes.

Event structure and framing

The four elements of xhttpsseevent: Type (the event: line — the event type name), Data (the data: lines — may be multi-line), Id (the id: line — the resume cursor), Retry (the retry: line — reconnection milliseconds suggested to the client); Flags declares which elements are present. SseSendEvent frames a complete event in one call; SseSendComment (the : heartbeat comment line — keep-alive); xrtHttpBodyStreamWrite/WriteRef/WriteTake write raw bytes directly (pre-encoding events, proxy forwarding, custom extensions — the structured Writer is not a mandatory path).

Backpressure and waiting

A producer write that hits a full budget returns AGAIN — no input reference is retained, the caller retries verbatim; xrtHttpBodyStreamWaitWritable returns the shared Future representing next-generation writability — one waiter cancelling affects the other waiters, so it must not face direct cancellation (Chapter 58's shared-Future semantics). This "AGAIN + WaitWritable" is the same vocabulary as Chapter 96's WebSocket Stream.

Client: connection and parser

The client side has two layers: the connection layer (http_sse_client — initiates the GET, maintains reconnection and the Last-Event-ID header) and the parser (the incremental syntax machine for text/event-stream — field lines, event boundaries, multi-line data joining). The parser configuration caps single lines, event data, type, and ID — a structural memory bound (Chapter 99's "what to cap is decided by content structure", SSE edition: the representation body is chosen to be unbounded, the structured memory bounded). Disconnect resume: the id: sent by the server is recorded by the client and carried back as the Last-Event-ID header on reconnection — the server resumes after the cursor.

Choosing between SSE and WebSocket

DimensionSSEWebSocket (Chapters 95–97)
Directionone-way (server → client)bidirectional
Protocola plain HTTP response streama separate protocol after upgrade
Infrastructuretransparent to any HTTP middlewareneeds Upgrade support
Reconnectbuilt into browser/client + cursor resumeapplication-built
Fitspush / streaming output / progresschat / collaboration / bidirectional telemetry

107.3示例

First complete program: events, heartbeats, and direct writes — three paths

The program below is from examples/http/sse_server — the complete face of structured event sending:

extlibs/xhttp/examples/http/sse_server/main.c源码 ↗
#include <stdio.h>
#include <string.h>
#include <xhttp.h>



/* 读取并打印 Reply 的完整异步正文。 */
static bool exampleReadReply(xhttpreply* pReply)
{
	xhttpbodyreader* pReader = xrtHttpBodyOpen(
		xrtHttpReplyBody(pReply)
	);
	xhttpbodychunk Chunk;
	xhttpbodystatus Status;

	if ( pReader == NULL ) {
		return false;
	}
	for ( ;; ) {
		Status = xrtHttpBodyNext(pReader, 8, &Chunk);
		if ( Status == XHTTP_BODY_DATA ) {
			(void)fwrite(Chunk.Data, 1, Chunk.Size, stdout);
			xrtHttpBodyChunkRelease(&Chunk);
			continue;
		}
		if ( Status == XHTTP_BODY_AGAIN ) {
			xfuture* pReady = xrtHttpBodyReaderWait(pReader);

			if ( (pReady == NULL) ||
				(xrtFutureWait(pReady) != XWAIT_OK) ||
				(xrtFutureState(pReady) != XFUTURE_RESOLVED) ) {
				xrtFutureDestroy(pReady);
				xrtHttpBodyReaderDestroy(pReader);
				return false;
			}
			xrtFutureDestroy(pReady);
			continue;
		}
		break;
	}
	xrtHttpBodyReaderDestroy(pReader);
	return Status == XHTTP_BODY_EOF;
}



/* 创建 SSE Reply,发送结构化事件、心跳和预封装字节。 */
int main(void)
{
	xhttpbodystreamconfig Config;
	xhttpbodystream* pStream = NULL;
	xhttpreply* pReply;
	xhttpsseevent Event;
	bool bResult;

	xrtHttpBodyStreamConfigInit(&Config);
	Config.MaxBytes = 4096;
	Config.MaxChunks = 32;
	pReply = xrtHttpSseReplyCreate(&Config, &pStream);
	if ( (pReply == NULL) || (pStream == NULL) ) {
		xrtHttpBodyStreamDestroy(pStream);
		xrtHttpReplyDestroy(pReply);
		return 1;
	}

	memset(&Event, 0, sizeof(Event));
	Event.Type = XRT_STR_LITERAL("progress");
	Event.Data = XRT_STR_LITERAL("{\"percent\":75}");
	Event.Id = XRT_STR_LITERAL("job-42:3");
	Event.Retry = 2000;
	Event.Flags = XHTTP_SSE_EVENT_TYPE |
		XHTTP_SSE_EVENT_DATA |
		XHTTP_SSE_EVENT_ID |
		XHTTP_SSE_EVENT_RETRY;
	if ( (xrtHttpSseSendEvent(
			pStream, &Event
		) != XHTTP_BODY_STREAM_OK) ||
		(xrtHttpSseSendComment(
			pStream, XRT_STR_LITERAL("heartbeat")
		) != XHTTP_BODY_STREAM_OK) ||
		(xrtHttpBodyStreamWrite(
			pStream, XRT_BYTES_LITERAL("data: ready\n\n")
		) != XHTTP_BODY_STREAM_OK) ) {
		xrtHttpBodyStreamDestroy(pStream);
		xrtHttpReplyDestroy(pReply);
		return 1;
	}

	xrtHttpBodyStreamDestroy(pStream);
	bResult = exampleReadReply(pReply);
	xrtHttpReplyDestroy(pReply);
	return bResult ? 0 : 1;
}
$ gcc -O1 -DXRT_MODULE_ALL -DXHTTP_MODULE_ALL -I single -I single/extlibs -include xrt.h -include xhttp.h impl.c extlibs/xhttp/examples/http/sse_server/main.c -lws2_32 -lbcrypt -ladvapi32 -liphlpapi(读回自检通过后正常退出)

What just happened. (1) BodyStreamConfigInit sets MaxBytes/MaxChunks (the producer budget — the source of the AGAIN threshold), then SseReplyCreate yields the Reply and Stream object pair. (2) Three write forms fired together: SseSendEvent (a progress event with all four elements — type/data/id/retry framed at once), SseSendComment (the heartbeat comment line — keep-alive without producing an event), BodyStreamWrite (the pre-framed data: ready\n\n bytes written directly — the path for proxy/static events). (3) Read-back verification (the sample's exampleReadReply): reads the Reply's body out streaming with Chapter 108's body reader and prints chunk by chunk — the bytes the server frames are exactly the syntax the client parser eats; one sample checks both ends against each other. (4) The two-object lifetime: the Stream is Destroyed when done (EOF published), the Reply Destroyed after — consistent with the contract "after commit the Reply may be destroyed, the Stream independent" (this sample does not commit to a connection; it reads back locally instead).

Second complete program: the waiting form of client-side stream reading

The second program is from examples/http/sse_client — the standard loop of the client's consuming side:

extlibs/xhttp/examples/http/sse_client/main.c源码 ↗
#include <stdio.h>
#include <string.h>
#include <xhttp.h>



/* 示例上下文只同步唯一终态,不在回调和宿主线程间共享借用视图。 */
typedef struct sse_example {
	xatomic32 Closed;
} sse_example;



/* 输出一段不保证以零字符结尾的 SSE 文本视图。 */
static void printView(cstr sName, xstrview Value)
{
	printf("%s: ", sName);
	if ( Value.Size != 0 ) {
		(void)fwrite(Value.Data, 1, Value.Size, stdout);
	}
	(void)fputc('\n', stdout);
}



/* 报告已经通过状态码和媒体类型校验的响应。 */
static bool onOpen(
	xhttpsseclient* pClient,
	const xhttpresponse* pResponse,
	ptr pData
)
{
	(void)pClient;
	(void)pData;
	printf("open: HTTP %u\n", (unsigned)xrtHttpResponseStatus(pResponse));
	return true;
}



/* 在网络 Worker 回调内消费借用消息,不把 Parser 视图带出回调。 */
static bool onMessage(
	xhttpsseclient* pClient,
	const xhttpssemessage* pMessage,
	ptr pData
)
{
	(void)pClient;
	(void)pData;
	printView("event", pMessage->Type);
	printView("id", pMessage->LastEventId);
	printView("data", pMessage->Data);
	(void)fputc('\n', stdout);
	return true;
}



/* 报告暂态断开;Client 随后按协商后的毫秒延迟自动重连。 */
static void onRetrying(
	xhttpsseclient* pClient,
	size_t iReconnect,
	uint64 iDelay,
	const xerror* pError,
	ptr pData
)
{
	(void)pClient;
	(void)pData;
	fprintf(
		stderr,
		"reconnect %zu after %llu ms: %s\n",
		iReconnect,
		(unsigned long long)iDelay,
		pError != NULL ? xrtErrorMessage(pError) : "stream ended"
	);
}



/* 发布唯一终态并唤醒示例宿主线程。 */
static void onClose(
	xhttpsseclient* pClient,
	xhttpsseclosereason Reason,
	const xerror* pError,
	ptr pData
)
{
	sse_example* pExample = (sse_example*)pData;

	(void)pClient;
	fprintf(
		stderr,
		"closed (%d): %s\n",
		(int)Reason,
		pError != NULL ? xrtErrorMessage(pError) : "normal stop"
	);
	xrtAtomic32Store(&pExample->Closed, 1, XMEMORY_RELEASE);
}



/* 连接一个 SSE URL,打印消息并等待服务端 204 或永久错误终止会话。 */
int main(int argc, char** argv)
{
	xnetengineconfig EngineConfig;
	xhttpclientconfig HttpConfig;
	xhttpsseclientevents Events;
	sse_example Example;
	xnetengine* pEngine = NULL;
	xhttpclient* pHttp = NULL;
	xhttpsseclient* pSse = NULL;
	int iExit = 1;

	if ( argc != 2 ) {
		fprintf(stderr, "usage: sse_client <http-url>\n");
		return 0;
	}
	memset(&Example, 0, sizeof(Example));
	memset(&Events, 0, sizeof(Events));
	Events.Open = onOpen;
	Events.Message = onMessage;
	Events.Retrying = onRetrying;
	Events.Close = onClose;
	Events.Data = &Example;

	xrtNetEngineConfigInit(&EngineConfig);
	pEngine = xrtNetEngineCreate(&EngineConfig);
	if ( (pEngine == NULL) || !xrtNetEngineStart(pEngine) ) {
		goto cleanup;
	}
	xrtHttpClientConfigInit(&HttpConfig);
	pHttp = xrtHttpClientCreate(pEngine, &HttpConfig);
	if ( pHttp == NULL ) {
		goto cleanup;
	}
	pSse = xrtHttpSseConnect(
		pHttp,
		(xstrview){ argv[1], strlen(argv[1]) },
		NULL,
		&Events
	);
	if ( pSse == NULL ) {
		goto cleanup;
	}

	while ( xrtAtomic32Load(&Example.Closed, XMEMORY_ACQUIRE) == 0 ) {
		xrtSleep(10);
	}
	iExit = xrtHttpSseClientError(pSse) == NULL ? 0 : 1;

cleanup:
	xrtHttpSseClientDestroy(pSse);
	xrtHttpClientDestroy(pHttp);
	if ( pEngine != NULL ) {
		while ( !xrtNetEngineDestroy(pEngine) ) {
			xrtClearError();
			xrtThreadYield();
		}
	}
	return iExit;
}
$ gcc -O1 -DXRT_MODULE_ALL -DXHTTP_MODULE_ALL -I single -I single/extlibs -include xrt.h -include xhttp.h impl.c extlibs/xhttp/examples/http/sse_client/main.c -lws2_32 -lbcrypt -ladvapi32 -liphlpapi(示例校验客户端事件消费路径后正常退出)

What just happened. (1) The body-read loop's three states: XHTTP_BODY_DATA (take the chunk, consume, ChunkRelease), XHTTP_BODY_AGAIN (nothing yet — xrtHttpBodyReaderWait takes a Future and waits for arrival), EOF (the stream ends normally). The AGAIN + Wait pair is the universal skeleton of streaming consumption (symmetric to the server's AGAIN + WaitWritable). (2) The SSE client's connection layer maintains this on top: reconnect per the Retry suggestion on disconnect, the Last-Event-ID cursor carried back; the event parser turns the byte stream into structured event callbacks. (3) The companion examples/http/sse_http (the bare HTTP form without the client layer — the response stream consumed directly) covers the "want the stream, not the reconnection" scenario.

107.4契约

  • No dedicated state machine: the SSE Reply is an ordinary Reply (200/text-event-stream/unknown length); sending rides the one and only response state machine with the existing backpressure and write deadline.
  • Protocol/application boundary: ReplyCreate sets only Content-Type; Cache-Control/CORS/buffering policy are yours to set before commit.
  • Lifecycle: after Respond the Reply may be destroyed, the Stream independent (held by a task thread/publisher); last-reference destruction = EOF after queued events; Close idempotent; Fail discards the undelivered + a stable Cause.
  • Write atomicity: validate and meter first, then encode into one node; no temporary event string; no half event on failure; byte/Chunk budgets cover reservations + queuing + leases.
  • Backpressure: AGAIN retains no input; WaitWritable is a shared Future (no unilateral cancellation); Write/WriteRef/WriteTake direct byte writes optional.
  • Event four elements: event/data/id/retry + Flags; the comment line keeps alive; id is the resume cursor.
  • Client parsing: an incremental syntax machine; single-line/data/type/ID four caps — structured memory bounded; Last-Event-ID resume.
  • Consume loop: DATA (chunk + Release) / AGAIN (Wait Future) / EOF, a three-state skeleton.
  • Trimming: HTTP_SSE server/client independent; the parser usable alone over custom transports.

Common Mistakes

Pitfall 1: putting data with bare newlines into an SSE event:Pitfall 1: putting data with bare newlines into an SSE event

Symptom: what the client receives as "one event" gets split into several or parses into garbage — newlines inside data changed the event boundary.

Cause: SSE separates events with blank lines, and multi-line data is expressed as multiple data: lines — SseSendEvent handles multi-line data correctly when framing (splitting it into multiple data: lines), but bypassing the structured entrance and splicing strings by hand easily writes the newlines bare.

错误写法
xrtHttpBodyStreamWrite(pStream, XRT_BYTES_LITERAL(
	"data: line1\nline2\n\n"));   /* line2 without a data: prefix: a syntax error */
正确写法
xhttpsseevent Event = { 0 };
Event.Data = XRT_STR_LITERAL("line1\nline2");   /* the framer splits multi-line data: */
Event.Flags = XHTTP_SSE_EVENT_DATA;
xrtHttpSseSendEvent(pStream, &Event);
Pitfall 2: ignoring AGAIN — busy-retrying or dropping:Pitfall 2: ignoring AGAIN — busy-retrying or dropping

Symptom: the producing task writes at full speed, spinning and burning CPU once the budget fills; or AGAIN is treated as an error and events dropped — the client misses events.

Cause: AGAIN is a backpressure signal (the same semantics as Chapter 96): the correct action is to wait for writability then send the same event — AGAIN retained no input; the data in your hands is the only copy.

错误写法
while ( xrtHttpSseSend(pStream, Data) == XHTTP_BODY_STREAM_AGAIN ) {
	/* tight loop: burns CPU and may never squeeze in */
}
正确写法
xhttpbodystreamresult R;
while ( (R = xrtHttpSseSend(pStream, Data)) ==
		XHTTP_BODY_STREAM_AGAIN ) {
	xfuture* pW = xrtHttpBodyStreamWaitWritable(pStream);
	if ( pW == NULL ) { break; }
	xrtFutureWaitFor(pW, Timeout);   /* wait, do not cancel the shared Future */
	xrtFutureDestroy(pW);
}
/* exiting the loop with R as OK/CLOSED: handle each */
Pitfall 3: forgetting X-Accel-Buffering — events batched up by the proxy:Pitfall 3: forgetting X-Accel-Buffering — events batched up by the proxy

Symptom: events arrive in real time on a direct local connection, but behind nginx the client "gets a clump after ages" — the proxy treats the response stream as a bufferable download and batches it up.

Cause: SSE's real-time nature depends on intermediaries not buffering; X-Accel-Buffering: no is the standard header informing the proxy — it is deployment-environment knowledge, so xhttp does not add it automatically (the protocol/application boundary); you add it.

错误写法
pReply = xrtHttpSseReplyCreate(NULL, &pStream);
respond(pConn, pReply);   /* deployed behind a proxy: events batched up */
正确写法
pReply = xrtHttpSseReplyCreate(NULL, &pStream);
xrtHttpReplySetHeader(pReply,
	XRT_STR_LITERAL("X-Accel-Buffering"),
	XRT_STR_LITERAL("no"));
xrtHttpReplySetHeader(pReply,
	XRT_STR_LITERAL("Cache-Control"),
	XRT_STR_LITERAL("no-cache"));
respond(pConn, pReply);

Programming Exercises

Basic: both ends checked against each other

Run sse_server and sse_client through; change the server event to a custom type + multi-line data, and have the client print the parsed structure. Acceptance criteria: multi-line data joined correctly; the id cursor's increments visible.

Advanced: a progress-push service

Add a /progress routing entry to Chapter 104's server: after a task is submitted, SSE pushes progress (a percent event every 100 ms, a heartbeat every 15 s); the producing task runs on its own thread, writing through the Stream. Acceptance criteria: with a slow client (rate-limited consumption) the server's memory stays constant (backpressure working — watch the AGAIN path); task completion sends a done event with id, then EOF.

Challenge: disconnect resume

The client consumes N events then disconnects; the reconnection carries Last-Event-ID; the server resumes after the cursor (cursor storage of your choice: an in-memory ring or Chapter 18's Map). Acceptance criteria: the client's event stream resumes seamlessly (no repeats, no gaps); when the server restarts (cursor lost) the client receives from the start (record this behavioral difference); heartbeats hold the connection through idle periods.

Summary

TopicQuick reference
Design philosophyno dedicated state machine: an ordinary Reply riding the one and only response state machine and existing backpressure
Two objectsReply (destroyable early) + Stream (independent producer); EOF/Close/Fail three finishes
Event four elementsevent/data/id/retry + Flags; SseSendEvent frames in one call; id = resume cursor
Atomic writesvalidate and meter first, then encode one node; no half event on failure; budgets cover reservations/queuing/leases
BackpressureAGAIN retains no input; WaitWritable shared Future, no unilateral cancel
Direct-write pathWrite/WriteRef/WriteTake — pre-encoded/proxy/extensions need not go through the structured Writer
Protocol/application boundaryContent-Type belongs to the layer; Cache-Control/CORS/X-Accel-Buffering belong to you
Clientconnection layer (reconnect + Last-Event-ID) + parser (four caps, structured memory bounded)
Consume loopDATA (chunk + Release) / AGAIN (Wait) / EOF three-state skeleton
Selectionone-way push uses SSE; bidirectional goes WebSocket — stronger is not better

📖 API references in this chapter:xhttp-http_sse (24 entries)xhttp-http_server (75 entries)xhttp-http_client (101 entries)

Click for full signatures, parameter constraints, return values and error notes