Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "khttpd",
version = "0.4.4",
version = "0.4.5",
)

bazel_dep(name = "platforms", version = "1.1.0")
Expand Down
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and [Boost.Asio](https://www.boost.org/doc/libs/release/libs/asio/), managed wit
- **Exception Handling** — Type-safe exception dispatcher with per-type handlers
- **Chunked Streaming** — Server-sent chunked transfer encoding via `HttpContext::chunked()`
- **Bidirectional HTTP Streaming** — Header-first request routing, fixed-buffer upload/download, proxy backpressure, and Range forwarding
- **Async Server-Sent Events** — Non-blocking SSE routes and client with incremental parsing, cancellation, and backpressure
- **Cookie Support** — Read / write cookies with configurable `CookieOptions` (path, domain, SameSite, etc.)
- **Form & Multipart** — `application/x-www-form-urlencoded` and `multipart/form-data` parsing (file uploads)
- **JSON** — Native `boost::json` integration with `get_json()`, `set_body_json()`, `set_body_from()`
Expand Down Expand Up @@ -168,6 +169,7 @@ framework/
├── client/
│ ├── http_client.hpp/cpp # Sync/async HTTP client with SSL
│ ├── http_client_stream.hpp/cpp # Fixed-buffer HTTP streaming client
│ ├── sse_client.hpp/cpp # Async Server-Sent Events client
│ ├── http_proxy_session.hpp/cpp # Bidirectional streaming proxy pump
│ └── websocket_client.hpp/cpp # WebSocket client
├── interceptor/
Expand All @@ -182,6 +184,9 @@ framework/
│ └── di_container.hpp # Type-indexed DI container (singleton)
├── session/
│ └── http_session.hpp/cpp # Per-connection HTTP session
├── sse/
│ ├── sse_parser.hpp/cpp # Incremental SSE parser and wire formatting
│ └── sse_session.hpp/cpp # Async server-side SSE write queue
└── websocket/
└── websocket_session.hpp/cpp # Per-connection WebSocket session
```
Expand Down Expand Up @@ -421,6 +426,67 @@ fixed-buffer behavior. Its default TLS context verifies the system trust store;
an application can inject an `ssl::context` into `HttpClientStream` or
`HttpProxySession` for private CAs and test certificates.

### Async Server-Sent Events

Register an SSE endpoint with `HttpRouter::sse`. The session serializes writes
through an owned queue, so callers may publish from different threads without
keeping event strings alive until the socket write completes.

```cpp
router.sse("/events",
[](HttpContext&, std::shared_ptr<sse::SseSession> session)
{
session->on_close([](boost::system::error_code ec) {
// Remove the subscriber from application state.
});
session->send({"config", R"({"name":"app.yaml"})", "42", 3000});
session->send_comment("heartbeat");

// Retain session in the application's subscriber collection when more
// events will be sent after this handler returns. Call close() for a
// graceful final chunk or cancel() to abort the connection.
});
```

The optional third `router.sse` argument sets the maximum queued wire bytes per
connection (1 MiB by default). `send()` returns `false` when that limit would be
exceeded, allowing the application to drop or resynchronize a slow subscriber.
SSE routes run the ordinary pre-request interceptor chain before their handler;
an authentication or authorization interceptor can reject the request before
any event-stream response headers are written.

The server also monitors the connection's read side while an SSE response is
open. A passive client disconnect completes the session and invokes `on_close`
even when the application has no event or heartbeat waiting to be written.

`SseClient` uses the same fixed-buffer streaming transport and delivers each
complete event as soon as it arrives. Arbitrarily split lines, CRLF/LF,
multiline `data`, comments, `id`, and numeric `retry` fields are supported.

The starter `example` exposes a visual `/events-demo` page and the raw `/events`
stream. After `bazel run //:app`, open the page in a browser or inspect the stream
with `curl -N http://127.0.0.1:8080/events`.

```cpp
auto events = std::make_shared<client::SseClient>(ioc); // 1 MiB event limit
events->connect(
"https://config.internal/events",
{{"Authorization", "Bearer internal-token"}, {"Last-Event-ID", "41"}},
[](const sse::SseEvent& event) {
// event.event, event.data, event.id, event.retry
},
[](boost::system::error_code ec) {
// Schedule reconnect/backoff in application code when appropriate.
});
```

Keep the `SseClient` alive for the duration of the subscription. `cancel()`
closes the transport and completes the close callback once with
`operation_aborted`. Automatic reconnect is intentionally left to the caller,
which can apply service-specific backoff and send `Last-Event-ID`.
The optional constructor limit bounds an unfinished line or event; exceeding it
closes only that subscription and reports `message_size`.

### Tests

Run the complete framework suite, including buffered-body boundaries, streaming
Expand Down
3 changes: 3 additions & 0 deletions doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
| [架构指南](architecture.md) | 框架设计、请求流程、线程模型、扩展点 |
| [高级功能](advanced.md) | 拦截器、异常处理、动态 WebSocket、双向 HTTP 流、Cron、DI、Cookie |
| [HTTP 与 WebSocket 客户端](http-client.md) | 缓冲/流式 HTTP 客户端、API_CALL 宏、WebSocket 帧客户端 |
| [异步 Server-Sent Events](server-sent-events.md) | SSE 服务端路由、异步客户端、心跳、取消与重连策略 |

## 按主题查找

Expand All @@ -33,6 +34,7 @@
- 设置响应 → [API 参考](api-reference.md#响应设置)
- 分块流式响应 → [高级功能](advanced.md#分块流式响应)
- 大文件双向流式代理 → [高级功能](advanced.md#双向-http-流与大文件代理)
- 异步 SSE 服务端 → [Server-Sent Events](server-sent-events.md#服务端)
- 普通请求体 413 上限 → [API 参考](api-reference.md#server)
- Cookie 操作 → [高级功能](advanced.md#cookie-操作)

Expand All @@ -57,6 +59,7 @@
- 多 Host 权重分发 → [HTTP 客户端](http-client.md#多-host-权重分发)
- API_CALL 宏 → [HTTP 客户端](http-client.md#api_call-宏自动生成客户端方法)
- WebSocket 客户端 → [HTTP 客户端](http-client.md#websocket-客户端)
- 异步 SSE 客户端 → [Server-Sent Events](server-sent-events.md#客户端)

### 架构
- 请求处理流程 → [架构指南](architecture.md#请求处理流程)
Expand Down
99 changes: 99 additions & 0 deletions doc/server-sent-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 异步 Server-Sent Events

khttpd 的 SSE 支持建立在异步流式 HTTP 传输之上,不占用阻塞线程。服务端通过
`SseSession` 顺序写入事件,客户端通过 `SseClient` 增量读取事件;普通响应、
`HttpContext::chunked()` 和 WebSocket 的处理路径不受影响。

## 服务端

使用 `HttpRouter::sse` 注册 GET 路由:

```cpp
#include "sse/sse_session.hpp"

router.sse("/events",
[](HttpContext&, std::shared_ptr<sse::SseSession> session)
{
session->on_close([](boost::system::error_code ec) {
// 从业务侧的订阅者集合移除连接。
});

session->send({
"config", // event
R"({"name":"app.yaml"})", // data,可包含多行
"42", // id
3000 // retry,毫秒
});
session->send_comment("heartbeat");
});
```

`router.sse` 的可选第三个参数用于设置单连接待发送队列的字节上限,默认 1 MiB:

```cpp
router.sse("/events", handler, 256 * 1024);
```

队列即将超过上限时 `send()` 返回 `false`,业务可丢弃慢订阅者或触发全量同步,
不会继续占用内存。SSE 路由在 handler 前运行普通的全局同步/异步拦截器,鉴权或
权限拦截器返回 `Stop` 时不会发送 SSE 响应头,也不会执行 handler。

响应会包含 `Content-Type: text/event-stream`、`Cache-Control: no-cache` 和
`X-Accel-Buffering: no`,正文使用 HTTP 分块传输。`send()` 和
`send_comment()` 可从不同线程调用,内部拥有待写入字符串并按 FIFO 顺序串行写入,
自然继承底层 socket 的背压。

如果路由处理函数返回后仍要继续推送,应用必须在订阅者集合中持有
`std::shared_ptr<SseSession>`。`close()` 会等待已排队事件写完再发送结束块;
`cancel()` 会立即中断连接。两者最终都只触发一次 `on_close`。

khttpd 会在响应流期间独立监听 TCP 读侧。客户端主动关闭连接时,即使服务端没有
后续事件写入,也会立即结束 `SseSession` 并触发一次 `on_close`,因此资源回收不依赖
业务心跳。心跳仍建议使用注释帧,例如 `send_comment("heartbeat")`,用于避免代理、
负载均衡器或 NAT 将空闲连接回收;心跳周期由业务服务决定。

仓库的 `example` 包含可直接运行的 `/events` 演示:连接后立即发送 `welcome` 和
首个 `tick` 事件,之后每秒继续推送。运行 `bazel run //:app` 后打开
`http://127.0.0.1:8080/events-demo` 可查看浏览器实时界面,也可使用
`curl -N http://127.0.0.1:8080/events` 查看原始事件流。定时器在连接关闭时取消,
避免为已断开的客户端保留后台任务。

## 客户端

```cpp
#include "client/sse_client.hpp"

auto subscription = std::make_shared<client::SseClient>(ioc); // 默认单事件上限 1 MiB
subscription->connect(
"https://config.internal/events",
{
{"Authorization", "Bearer internal-token"},
{"Last-Event-ID", "41"},
},
[](const sse::SseEvent& event) {
// event.event、event.data、event.id、event.retry
},
[](boost::system::error_code ec) {
// 按业务策略决定是否重连。
});
```

客户端要求上游返回 HTTP 200,且 Content-Type 必须为 `text/event-stream`
(允许带 charset 等参数)。解析器支持任意网络分片、CRLF/LF、多行 `data`、注释、
`event`、`id` 和纯数字 `retry`;无效的 `retry` 会被忽略。

调用方需要在订阅期间持有 `SseClient`。显式调用 `cancel()` 会关闭传输,并以
`operation_aborted` 调用一次关闭回调。框架不自动重连:调用方可以结合事件的
`retry`、指数退避和 `Last-Event-ID` 实现符合具体服务需求的恢复策略。
构造函数的可选字节上限用于限制未完成行或单个事件;超限只会关闭当前订阅,并以
`message_size` 完成关闭回调,不会终止进程。

事件和关闭回调中的异常会被框架记录并收敛:事件回调异常会取消当前订阅并以
`operation_aborted` 关闭,关闭回调异常不会穿透 Asio 的执行器。应用仍应自行处理
业务错误,避免把可恢复错误作为控制流异常。

## 选择 SSE 还是 WebSocket

- 服务器向客户端持续单向推送配置、实例或通知时,优先使用 SSE。
- 需要全双工消息、二进制帧或自定义控制帧时,使用 WebSocket。
- 需要上传和下载同时承受背压时,使用双向 HTTP 流式接口。
8 changes: 8 additions & 0 deletions example/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ cc_binary(
"HelloController.hpp",
"HelloStreamController.hpp",
"HelloWsController.hpp",
"SseDemo.hpp",
"TypedHelloController.hpp",
"main.cpp",
],
Expand Down Expand Up @@ -42,3 +43,10 @@ sh_test(
data = [":app"],
tags = ["exclusive"],
)

sh_test(
name = "sse_demo_test",
srcs = ["sse_demo_test.sh"],
data = [":app"],
tags = ["exclusive"],
)
2 changes: 1 addition & 1 deletion example/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ bazel_dep(name = "boost", version = "1.90.0.bcr.1")
bazel_dep(name = "boost.asio", version = "1.90.0.bcr.1")
bazel_dep(name = "boost.mysql", version = "1.90.0.bcr.1")
bazel_dep(name = "spdlog", version = "1.17.0")
bazel_dep(name = "khttpd", version = "0.4.2")
bazel_dep(name = "khttpd", version = "0.4.5")
local_path_override(
module_name = "khttpd",
path = "..",
Expand Down
Loading
Loading