Skip to content
bitzorcas
中EN

Concept

Server-Sent Events

SSE 的当前未交付边界、适用场景和未来实现必须满足的协议与运维要求。

Last updated

当前仓库没有生产 SSE 端点或适配器。Chat 已使用 SignalR;Notification Inbox 通过普通查询提供可靠状态。本文记录接入 SSE 时必须满足的边界,不把示例当成现有能力。

SSE 适合服务端到浏览器的单向提示,例如“收件箱有变化”或长任务进度。它不适合需要双向交互、二进制帧或复杂频道协商的聊天协议。

关键路径图

下图展示了 Server-Sent Events (SSE) 单向流式推送通道:服务端通过持久化事件流向前端实时推送进度通知与状态更新。

已授权请求

租户化事件流

异步事件源

保活与取消

客户端重连

实现要求

  • 连接前完成认证,订阅资源仍执行租户和授权检查。
  • 使用稳定 id 和 Last-Event-ID 支持断线续传;恢复数据来自持久化游标。
  • 定期发送注释心跳,设置代理缓冲、空闲超时和最大连接时长。
  • 每个调用者限制连接数,有界缓冲,慢客户端不能拖住生产者。
  • 发布端不能只用进程内 Channel;多实例需要共享事件来源或可回放 Store。

SSE 提示仍是至少一次,客户端要去重并重新查询事实来源。若以后交付 SSE,应新增端到端代理测试、断线恢复测试、容量基线和就绪探针;在此之前产品文案不得宣称支持。

协议帧与游标

每个事件由 id、event、一个或多个 data 行组成,以空行结束。id 必须能映射到持久化序列或 opaque cursor;进程内自增数在重启或多实例下不可恢复。

id: 01J2M7X5
event: notification.changed
data: {"notificationId":"N-1001","version":4}
: keep-alive

心跳使用注释行,不触发业务事件。payload 应是“发生变化”的最小提示,客户端随后调用受授权 REST 查询事实,避免在长期连接上复制完整 PII。

浏览器重连会发送 Last-Event-ID。服务端要验证该游标属于当前 tenant/user/channel;未知、过期或越权游标返回稳定错误或从明确的安全起点恢复,不能跨租户补发。

Endpoint 形态(规划)

下面是未来实现骨架,不代表仓库已有路由。Endpoint 必须在写响应前完成认证与订阅授权,并一路传播 RequestAborted。

// 规划中的 Endpoint:先建立可信订阅,再写 text/event-stream。
app.MapGet("/api/notifications/stream", async (
HttpContext http,
INotificationEventStream stream) =>
{
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
http.Response.Headers.Append("X-Accel-Buffering", "no");
// Last-Event-ID 先由 stream 按当前用户和租户验证。
var cursor = http.Request.Headers["Last-Event-ID"].ToString();
await foreach (var item in stream.ReadAsync(cursor, http.RequestAborted))
{
await http.Response.WriteAsync($"id: {item.Id}\nevent: {item.Type}\ndata: {item.Json}\n\n", http.RequestAborted);
await http.Response.Body.FlushAsync(http.RequestAborted);
}
}).RequireAuthorization();

生产实现还需要编码/换行处理,不能把用户文本直接拼入 frame;JSON data 中的换行必须按 SSE 规则拆分或安全序列化。

背压与资源预算

一个慢客户端不能拥有无界 Channel。每条连接使用有界缓冲,策略可以断开慢连接、丢弃可恢复提示或合并同一资源的多次变化;不可恢复事实必须进入持久 Store,而不是强行塞入内存队列。

预算至少包含每用户连接数、每租户连接数、全局连接数、buffer 长度、最大 event bytes、心跳、空闲/总时长和重连速率。匿名或 token 即将过期的连接也必须有明确关闭策略。

多实例与代理

负载均衡会把重连请求送到不同实例,因此恢复只能依赖共享 durable source。Sticky session 可以减少切换,不能替代持久游标。

Nginx/CDN/SLB 需要关闭响应缓冲,延长 idle timeout,允许 chunked streaming,并禁用会聚合响应的压缩/缓存策略。发布测试必须经过真实入口,直接访问 Kestrel 不能证明代理链工作。

Terminal window
# 未来端点交付后,用无缓冲模式观察帧和心跳是否持续到达。
curl -N \
-H 'Authorization: Bearer <test-token>' \
-H 'Last-Event-ID: 01J2M7X5' \
https://api.example.com/api/notifications/stream

认证、撤销与租户变化

连接建立时的 JWT 可能在流持续期间过期,Membership/权限也可能被撤销。实现要选择定期复核、短最大连接时长或 server-side revocation signal,并证明撤销在可接受窗口内生效。

Delegation/Tenant Impersonation session 比主 token 更短,SSE 不能让已过期的代理上下文继续接收客户事件。审计记录连接/断开与原因,不记录每个敏感 payload 正文。

客户端恢复

// EventSource 原生 API 不能方便地设置 Authorization header;
// 采用 cookie 时要处理 CSRF/Origin,采用 fetch-stream 时显式带 Bearer。
const response = await fetch("/api/notifications/stream", {
headers: { Authorization: `Bearer ${accessToken}`, "Last-Event-ID": cursor },
signal: abortController.signal,
});
// 每条提示按 id 去重,再从 REST 获取可授权的最新事实。
await consumeSse(response.body, event => refreshFromApi(event.id));

交付测试

  • 无认证、跨租户 cursor、撤销 Membership 和代理过期全部断流;
  • 重连到不同实例仍从 Last-Event-ID 补齐且不越权;
  • 慢客户端、有界队列、断网和取消不泄漏 Task/连接;
  • 代理缓冲关闭,心跳在生产入口可见;
  • 重复/乱序提示由 id 去重,REST 仍是事实源;
  • 容量测试给出连接、内存、CPU、带宽与恢复风暴基线;
  • readiness 能区分未配置事件源和可服务状态。

现有双向实时实现见实时通信。

生产防护

SSE 是传输受限事件视图的单向通道。服务端在建流前完成认证,每条事件都按租户与资源范围过滤,并在客户端断开时及时停止工作。

  • 发送事件标识,让客户端重连时避免盲目重复。
  • 使用保活与有界缓冲,慢客户端不能耗尽内存。
  • 暴露连接数、断开原因、延迟和丢弃事件指标。

当前结论

仓库当前只有本页设计约束,没有 MapGet 的 SSE 生产路由、事件源、持久游标或运维指标。以上代码与命令是验收目标;在实现、Consumer Contract Test 与真实代理验证完成前,SSE 仍是 planned capability。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%