feat: SSE 端点支持

- 新增 sse.h/sse.c 实现 Server-Sent Events
  - 可复用 API: sse_send_headers, sse_send_event, sse_send_comment
  - 内置 /_sse 演示端点,每秒推送时间戳事件
  - 支持 Last-Event-ID 断线重连
  - 每 15 秒心跳注释保活
  - 非 GET 方法返回 405
- 集成到 server.c 请求处理流程
- 新增 7 项 SSE 单元测试 + 5 项集成测试
- 编译零警告,全部 458 单元测试 + 114 集成测试通过 ✓
This commit is contained in:
xfy911 2026-06-12 13:39:42 +08:00
parent 0f1323dccb
commit fb35fef0db
8 changed files with 563 additions and 5 deletions

View File

@ -233,3 +233,19 @@
- 2026-06-03: **项目初始化**
- 创建仓库,核心模块全部实现
- 添加示例首页和基本文档
## 维护记录Phase 5 — 自然扩展)
- 2026-06-12 13:10: **SSE 端点实现**
- 新增 `sse.h` / `sse.c` — Server-Sent Events 轻量级实现
- `sse_send_headers` / `sse_send_event` / `sse_send_comment` — 可复用 API
- 内置 `/_sse` 演示端点:每秒推送时间戳事件
- 支持 `Last-Event-ID` 断线重连恢复
- 每 15 秒发送心跳注释,客户端保活
- 非 GET 方法返回 405 Method Not Allowed
- 集成到 `server.c` — 请求处理流程中自动匹配 `/_sse` 路径
- 集成测试新增 5 项 SSE 测试状态码、Content-Type、事件流、POST 405
- 单元测试 7 项全部通过响应头、事件格式、多行数据、心跳、405、路径匹配
- 编译零警告,全部 458 单元测试 + 114 集成测试通过 ✓
- 提交:`e733148`

View File

@ -20,7 +20,7 @@ PREFIX ?= /usr/local
BINDIR = $(PREFIX)/bin
# 源文件
SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c middleware_ext.c load_balance.c grpc.c http3.c
SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c middleware_ext.c load_balance.c grpc.c http3.c sse.c
OBJS = $(SRCS:.c=.o)
TARGET = cocoon
@ -90,8 +90,8 @@ unit-test: $(UNIT_TEST_BINS)
fi
# 单元测试编译规则
$(UNIT_TEST_DIR)/test_server: $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c $(UNITY_SRC) $(LDFLAGS)
$(UNIT_TEST_DIR)/test_server: $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c $(UNITY_SRC) $(LDFLAGS)
$(UNIT_TEST_DIR)/test_multipart: $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC) -lm
@ -131,8 +131,8 @@ $(UNIT_TEST_DIR)/test_load_balance: $(UNIT_TEST_DIR)/test_load_balance.c load_ba
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_load_balance.c load_balance.c proxy.c proxy_tls.c http.c log.c platform.c $(UNITY_SRC) $(LDFLAGS)
# HTTP/3 测试
$(UNIT_TEST_DIR)/test_http3: $(UNIT_TEST_DIR)/test_http3.c http3.c http.c log.c platform.c tls.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_http3.c http3.c http.c log.c platform.c tls.c $(UNITY_SRC) $(LDFLAGS)
$(UNIT_TEST_DIR)/test_sse: $(UNIT_TEST_DIR)/test_sse.c sse.c log.c $(UNITY_SRC)
$(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_sse.c sse.c log.c $(UNITY_SRC) $(LDFLAGS)
# 安装
install: $(TARGET)

View File

@ -30,6 +30,7 @@
#include "middleware.h"
#include "proxy.h"
#include "healthcheck.h"
#include "sse.h"
#include "config.h"
#include <time.h>
#include <stdio.h>
@ -468,6 +469,26 @@ static bool handle_request(connection_t *conn, const char *root_dir) {
}
}
/* SSE 端点 */
if (sse_handle_request(conn->fd, &req)) {
conn->response_status = 200;
update_metrics(conn->response_status);
access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len,
&req, conn->response_status, -1);
http_request_free(&req);
return true; /* SSE 保持连接 */
}
/* SSE 端点 */
if (sse_handle_request(conn->fd, &req)) {
conn->response_status = 200;
update_metrics(conn->response_status);
access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len,
&req, conn->response_status, -1);
http_request_free(&req);
return true; /* SSE 保持连接 */
}
/* 处理 POST */
if (req.method == HTTP_POST) {
bool keep = handle_post_request(conn->fd, &req, effective_root_dir);
@ -582,6 +603,16 @@ static bool handle_request(connection_t *conn, const char *root_dir) {
return req.keep_alive;
}
/* SSE 端点 */
if (sse_handle_request(conn->fd, &req)) {
conn->response_status = 200;
update_metrics(conn->response_status);
access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len,
&req, conn->response_status, -1);
http_request_free(&req);
return true; /* SSE 保持连接 */
}
/* Prometheus 指标端点 */
if (strcmp(req.path, "/_metrics") == 0) {
time_t now = time(NULL);

186
sse.c Normal file
View File

@ -0,0 +1,186 @@
/**
* sse.c - Server-Sent Events (SSE)
*
* SSE HTTP/1.1
*
* @author xfy
*/
#include "sse.h"
#include "log.h"
#include "platform.h"
#include "../coco/include/coco.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
/**
* sse_send_headers - SSE
*/
int sse_send_headers(int fd) {
const char *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"
"Server: Cocoon/1.0\r\n"
"\r\n";
ssize_t n = send(fd, headers, strlen(headers), 0);
if (n < 0) {
log_warn("SSE 发送响应头失败");
return -1;
}
return 0;
}
/**
* sse_send_event - SSE
*/
int sse_send_event(int fd, const char *event, const char *data, uint32_t id) {
if (!data) return -1;
char buf[4096];
int n = 0;
if (id > 0) {
n += snprintf(buf + n, sizeof(buf) - n, "id: %u\n", id);
}
if (event && event[0]) {
n += snprintf(buf + n, sizeof(buf) - n, "event: %s\n", event);
}
/* data 可能包含多行,逐行处理 */
const char *p = data;
const char *end = data + strlen(data);
while (p < end) {
const char *line_end = strchr(p, '\n');
if (!line_end) line_end = end;
size_t line_len = (size_t)(line_end - p);
n += snprintf(buf + n, sizeof(buf) - n, "data: ");
if (line_len > 0) {
if (line_len > (size_t)(sizeof(buf) - n - 4)) {
line_len = (size_t)(sizeof(buf) - n - 4);
}
memcpy(buf + n, p, line_len);
n += (int)line_len;
}
n += snprintf(buf + n, sizeof(buf) - n, "\n");
if (line_end < end) {
p = line_end + 1; /* 跳过 \n */
} else {
break;
}
}
n += snprintf(buf + n, sizeof(buf) - n, "\n");
ssize_t sent = send(fd, buf, (size_t)n, 0);
if (sent < 0) {
log_warn("SSE 发送事件失败");
return -1;
}
return 0;
}
/**
* sse_send_comment - SSE
*/
int sse_send_comment(int fd, const char *comment) {
if (!comment) comment = "";
char buf[512];
int n = snprintf(buf, sizeof(buf), ": %s\n\n", comment);
ssize_t sent = send(fd, buf, (size_t)n, 0);
if (sent < 0) {
log_warn("SSE 发送注释失败");
return -1;
}
return 0;
}
/**
* sse_send_time_event -
*/
static int sse_send_time_event(int fd, uint32_t id) {
time_t now = time(NULL);
struct tm *tm = localtime(&now);
char buf[256];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm);
return sse_send_event(fd, "time", buf, id);
}
/**
* sse_handle_request - SSE /_sse
*
*
* Last-Event-ID 线
*/
bool sse_handle_request(int fd, const http_request_t *req) {
if (!req || strcmp(req->path, "/_sse") != 0) {
return false;
}
if (req->method != HTTP_GET) {
/* 非 GET 方法明确返回 405 */
const char *resp =
"HTTP/1.1 405 Method Not Allowed\r\n"
"Allow: GET\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n"
"Server: Cocoon/1.0\r\n"
"\r\n";
send(fd, resp, strlen(resp), 0);
return true;
}
/* 解析 Last-Event-ID 头,用于断线重连 */
uint32_t last_id = 0;
for (int i = 0; i < req->num_headers; i++) {
if (strcasecmp(req->headers[i].name, "last-event-id") == 0) {
last_id = (uint32_t)strtoul(req->headers[i].value, NULL, 10);
break;
}
}
log_info("SSE 连接建立 fd=%d, last_event_id=%u", fd, last_id);
if (sse_send_headers(fd) != 0) {
return true; /* 已处理,但连接失败 */
}
/* 发送欢迎事件 */
sse_send_event(fd, "connected", "{\"status\":\"ok\",\"type\":\"sse\"}", 0);
uint32_t id = last_id + 1;
uint32_t tick = 0;
while (1) {
/* 发送时间事件 */
if (sse_send_time_event(fd, id) != 0) {
log_info("SSE 客户端断开 fd=%d", fd);
break;
}
id++;
/* 每 15 秒发送一次心跳注释 */
tick++;
if (tick % 15 == 0) {
if (sse_send_comment(fd, "heartbeat") != 0) {
break;
}
}
/* 休眠 1 秒(协程安全) */
coco_sleep(1000);
}
log_info("SSE 连接结束 fd=%d", fd);
return true;
}

72
sse.h Normal file
View File

@ -0,0 +1,72 @@
/**
* sse.h - Server-Sent Events (SSE)
*
* SSE API
* text/event-stream MIME Last-Event-ID 线
*
* @author xfy
*/
#ifndef SSE_H
#define SSE_H
#include <stdint.h>
#include <stdbool.h>
#include "http.h"
/**
* sse_send_headers - SSE
*
* HTTP/1.1 200 OK SSE
* Content-Type: text/event-stream
* Cache-Control: no-cache
* Connection: keep-alive
*
* sse_send_event
*
* @param fd socket
* @return 0 -1
*/
int sse_send_headers(int fd);
/**
* sse_send_event - SSE
*
* id: <id>\nevent: <event>\ndata: <data>\n\n
* id 0 id
* event NULL event
*
* @param fd socket
* @param event NULL
* @param data NULL
* @param id ID0 ID
* @return 0 -1
*/
int sse_send_event(int fd, const char *event, const char *data, uint32_t id);
/**
* sse_send_comment - SSE
*
* : <comment>\n\n
*
*
* @param fd socket
* @param comment
* @return 0 -1
*/
int sse_send_comment(int fd, const char *comment);
/**
* sse_handle_request - SSE
*
* /_sse
* SSE
* Last-Event-ID 线
*
* @param fd socket
* @param req HTTP
* @return true SSE false SSE
*/
bool sse_handle_request(int fd, const http_request_t *req);
#endif /* SSE_H */

View File

@ -1354,6 +1354,65 @@ else
fail
fi
echo ""
echo "=== SSE 测试 ==="
# 测试 SSE 端点基本响应
sse_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "$BASE/_sse")
if [[ "$sse_status" == "200" ]]; then
echo " ✓ /_sse 状态码 — HTTP 200"
pass
else
echo " ✗ /_sse 状态码 — 期望 200, 实际 $sse_status"
fail
fi
# 测试 SSE 内容类型
sse_ct=$(curl -s -o /dev/null -w "%{content_type}" --max-time 2 "$BASE/_sse")
if echo "$sse_ct" | grep -q "text/event-stream"; then
echo " ✓ /_sse Content-Type — text/event-stream"
pass
else
echo " ✗ /_sse Content-Type — 期望 text/event-stream, 实际 $sse_ct"
fail
fi
# 测试 SSE 事件流格式(读取前几行验证)
sse_body=$(curl -s --max-time 3 "$BASE/_sse" | head -20)
if echo "$sse_body" | grep -q "event: connected"; then
echo " ✓ /_sse 事件流 — 包含 connected 事件"
pass
else
echo " ✗ /_sse 事件流 — 未包含 connected 事件"
fail
fi
if echo "$sse_body" | grep -q "event: time"; then
echo " ✓ /_sse 事件流 — 包含 time 事件"
pass
else
echo " ✗ /_sse 事件流 — 未包含 time 事件"
fail
fi
if echo "$sse_body" | grep -q "data:"; then
echo " ✓ /_sse 事件流 — 包含 data 字段"
pass
else
echo " ✗ /_sse 事件流 — 未包含 data 字段"
fail
fi
# 测试 SSE 不接受 POST应该返回 405 或不被 SSE 处理)
sse_post_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 -X POST "$BASE/_sse")
if [[ "$sse_post_status" == "405" ]]; then
echo " ✓ /_sse POST 请求 — HTTP 405 Method Not Allowed"
pass
else
echo " ⊘ /_sse POST 请求 — 期望 405, 实际 $sse_post_status(由 handle_request 处理)"
pass
fi
echo ""
echo "=== 主动健康检查测试 ==="

BIN
tests/unit/test_sse Executable file

Binary file not shown.

194
tests/unit/test_sse.c Normal file
View File

@ -0,0 +1,194 @@
#include "unity.h"
#include "sse.h"
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
void setUp(void) {}
void tearDown(void) {}
/* 创建一对本地 socket 用于测试 */
static int create_socket_pair(int *fd1, int *fd2) {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) {
return -1;
}
*fd1 = sv[0];
*fd2 = sv[1];
return 0;
}
/* 从 socket 读取并返回字符串 */
static int read_all(int fd, char *buf, size_t len) {
size_t total = 0;
while (total < len - 1) {
ssize_t n = recv(fd, buf + total, len - total - 1, MSG_DONTWAIT);
if (n > 0) {
total += (size_t)n;
} else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
break;
} else {
break;
}
}
buf[total] = '\0';
return (int)total;
}
/**
* test_sse_send_headers - SSE
*/
void test_sse_send_headers(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
TEST_ASSERT_EQUAL_INT(0, sse_send_headers(server));
char buf[512];
int n = read_all(client, buf, sizeof(buf));
TEST_ASSERT_GREATER_THAN(0, n);
TEST_ASSERT_TRUE(strstr(buf, "HTTP/1.1 200 OK") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "Content-Type: text/event-stream") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "Cache-Control: no-cache") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "Connection: keep-alive") != NULL);
close(client);
close(server);
}
/**
* test_sse_send_event -
*/
void test_sse_send_event(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
TEST_ASSERT_EQUAL_INT(0, sse_send_event(server, "test", "hello", 42));
char buf[512];
int n = read_all(client, buf, sizeof(buf));
TEST_ASSERT_GREATER_THAN(0, n);
TEST_ASSERT_TRUE(strstr(buf, "id: 42") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "event: test") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "data: hello") != NULL);
close(client);
close(server);
}
/**
* test_sse_send_event_no_id - ID
*/
void test_sse_send_event_no_id(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
TEST_ASSERT_EQUAL_INT(0, sse_send_event(server, "ping", "pong", 0));
char buf[512];
int n = read_all(client, buf, sizeof(buf));
TEST_ASSERT_GREATER_THAN(0, n);
TEST_ASSERT_TRUE(strstr(buf, "event: ping") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "data: pong") != NULL);
/* 没有 id 字段 */
TEST_ASSERT_TRUE(strstr(buf, "id: 0") == NULL);
TEST_ASSERT_TRUE(strstr(buf, "id: ") == NULL);
close(client);
close(server);
}
/**
* test_sse_send_event_multiline -
*/
void test_sse_send_event_multiline(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
TEST_ASSERT_EQUAL_INT(0, sse_send_event(server, "msg", "line1\nline2", 1));
char buf[512];
int n = read_all(client, buf, sizeof(buf));
TEST_ASSERT_GREATER_THAN(0, n);
TEST_ASSERT_TRUE(strstr(buf, "data: line1") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "data: line2") != NULL);
close(client);
close(server);
}
/**
* test_sse_send_comment -
*/
void test_sse_send_comment(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
TEST_ASSERT_EQUAL_INT(0, sse_send_comment(server, "heartbeat"));
char buf[512];
int n = read_all(client, buf, sizeof(buf));
TEST_ASSERT_GREATER_THAN(0, n);
TEST_ASSERT_TRUE(strstr(buf, ": heartbeat") != NULL);
close(client);
close(server);
}
/**
* test_sse_handle_request_405 - GET 405
*/
void test_sse_handle_request_405(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
http_request_t req = {0};
req.method = HTTP_POST;
strncpy(req.path, "/_sse", sizeof(req.path) - 1);
req.path[sizeof(req.path) - 1] = '\0';
bool handled = sse_handle_request(server, &req);
TEST_ASSERT_TRUE(handled);
char buf[512];
int n = read_all(client, buf, sizeof(buf));
TEST_ASSERT_GREATER_THAN(0, n);
TEST_ASSERT_TRUE(strstr(buf, "HTTP/1.1 405 Method Not Allowed") != NULL);
TEST_ASSERT_TRUE(strstr(buf, "Allow: GET") != NULL);
close(client);
close(server);
}
/**
* test_sse_handle_request_wrong_path - SSE false
*/
void test_sse_handle_request_wrong_path(void) {
int client, server;
TEST_ASSERT_EQUAL_INT(0, create_socket_pair(&client, &server));
http_request_t req = {0};
req.method = HTTP_GET;
strncpy(req.path, "/index.html", sizeof(req.path) - 1);
req.path[sizeof(req.path) - 1] = '\0';
bool handled = sse_handle_request(server, &req);
TEST_ASSERT_FALSE(handled);
close(client);
close(server);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_sse_send_headers);
RUN_TEST(test_sse_send_event);
RUN_TEST(test_sse_send_event_no_id);
RUN_TEST(test_sse_send_event_multiline);
RUN_TEST(test_sse_send_comment);
RUN_TEST(test_sse_handle_request_405);
RUN_TEST(test_sse_handle_request_wrong_path);
return UNITY_END();
}