# 服务器发送事件(SSE) Server-Sent Events 通过纯 HTTP 单向流式传输事件 —— 从服务器到客户端。路由处理器用 `http/sse` 打开一个 `text/event-stream` 响应,响应头会立即写入并刷新,连接随即脱离常规的请求/响应生命周期:事件以 HTTP/1.1 分块传输帧的形式追加,直到流关闭或客户端断开。浏览器会自动重连,因此既没有轮询循环,也没有 WebSocket 握手。 ## `http/sse*` 标准库函数 | 函数 | 返回值 | 用途 | |---|---|---| | `[http/sse $res ?opts]` | `%SseStream{}` | 打开 `text/event-stream` 响应 | | `[http/sse-send $stream $data ?opts]` | `true` / `%Err` | 发送一个事件(`:event` 名称、`:id` 标识) | | `[http/sse-comment $stream $text]` | `true` / `%Err` | 发送注释行 —— 手动保活 | | `[http/sse-close $stream]` | `true` / `false` | 将终止块入队并关闭 | ## 打开流
def events {|req res|
    set stream [http/sse $res %{ :retry 3000 :keep-alive 15000 }]
    [http/sse-send $stream "hello"]
    [http/sse-close $stream]
    nil
}
`http/sse` 选项: | 选项 | 含义 | |---|---| | `:retry` | 初始重连延迟(毫秒),作为 `retry:` 字段发送 | | `:keep-alive` | 原生定时器上空闲时的 `: ping` 注释间隔(毫秒) | | `:status` | 状态码覆盖(默认 200) | | `:headers` | 额外的响应头;`[http/header ...]` 优先 | | `:on-close` | 拆除时触发 `{|stream| ...}`(见下文) | 该响应带有 `Content-Type: text/event-stream`、`Cache-Control: no-cache, no-store`、`Connection: keep-alive` 以及 `Transfer-Encoding: chunked`;`X-Accel-Buffering: no` 可防止 nginx 之类的反向代理缓冲该流。`Content-Length` 永远不会被发送。通过 `[http/header $res ...]` 设置的响应头会替换匹配的默认头;`:headers` 选项只会添加行,因此要覆盖 `Content-Type` 之类的默认头时应使用 `[http/header ...]`。 当 `$res` 不是 `%Response{}`、响应已被写入(`http/respond`、`http/json`、`http/html`、`http/redirect`)、已经是 SSE 流,或连接已在流式传输时,`http/sse` 返回 `%Err`。客户端在首次刷新时断开也会让它返回 `%Err`。 ## 发送事件 `http/sse-send` 写入并刷新一个事件。仅数据:
[http/sse-send $stream "hello"]
带命名事件和 id:
[http/sse-send $stream "payload" %{ :event "tick" :id "42" }]
多行数据会逐行拆分为一条条 `data:` 行。`:id` 让浏览器在重连后通过 `Last-Event-ID` 请求头续传。`http/sse-send` 返回 `true`,流关闭后则返回 `%Err`(客户端消失、慢消费者拆除、显式关闭)。 `http/sse-comment` 发送注释行(`: text`),可用作应用层保活。`http/sse-close` 将终止块入队、将其排空并关闭连接;流已关闭时返回 `false`。 ## 处理器挂起 处理器以协程方式运行,因此 SSE 处理器可以在事件之间挂起。`[sleep]` 和 `[await ...]` 只让出当前处理器 —— 其他连接继续运行:
def ticks {|req res|
    set stream [http/sse $res]
    for i in @{ 1 2 3 } {
        [http/sse-send $stream [str "tick-" $i] %{ :event "tick" }]
        [sleep 1000]
    }
    [http/sse-close $stream]
    nil
}
恢复后写入的事件会到达同一个流。 ## 扇出模式(发布/订阅,集群安全) 常见做法是把活动流保存在应用级映射中,并从具名的发布/订阅处理器推送。客户端离开时由 `:on-close` 移除对应条目:
def clients [ref %{}]        # request id -> %SseStream{}

def on-tick {|ev|
    [values [deref $clients]] |> each {|s| [http/sse-send $s $ev~data] }
}

def events {|req res|
    set id $req~id
    set stream [http/sse $res %{ :keep-alive 15000
                                 :on-close {|s| [swap! $clients {|m| [dissoc $m $id]}]} }]
    [swap! $clients {|m| [assoc $m $id $stream]}]
    [pub/subscribe "ticks" "on-tick"]
    nil
}
`$req~id`(请求 id)是客户端映射中便利的稳定键。集群模式下每个工作进程都会订阅;来自任意运行时的发布通过共享 broker 到达其他工作进程,而每个工作进程只写自己的套接字。 ## 客户端 浏览器用 `EventSource` 消费该流: ```javascript const es = new EventSource("/events"); es.onmessage = (e) => console.log(e.data); es.addEventListener("tick", (e) => console.log("tick:", e.data)); es.onerror = () => { // readyState === EventSource.CLOSED means the server closed the stream }; ```` 命名事件(`:event`)分派到 `addEventListener`;未命名事件触发 `onmessage`。经过 `retry` 间隔后会自动重连。若要支持断点续传,请在服务端读取 `last-event-id` 请求头(`[get $req~headers "last-event-id"]`),并从自己的事件日志重放。 在 shell 中,`curl -N http://localhost:8080/events` 会在帧到达时打印它们。 ## 需要遵守的语义 - 分块、单向、以关闭定界。 `http/sse-close` 写入 `0\r\n\r\n` 终止块并关闭套接字;SSE 连接永远不会被归还给 keep-alive。 - 流是原生资源。 丢弃最后一个引用后,GC 终结器会拆除连接(关闭 fd、释放待处理缓冲区)。GC 拆除时不会触发 `:on-close` —— 只有显式关闭、客户端断开和处理器失败才会回调进 VM。 - 慢客户端会被切断。 每个连接的待发送输出上限为 1 MiB;超出上限即关闭流(且 `http/sse-send` 返回 `%Err`),而不是无限制地缓冲。 - 重连是客户端的职责。 `EventSource` 会自动重试;若需要重放,请使用 `:id` + `Last-Event-ID`。 - 集群重载会丢弃活动流,与任何进行中的请求一样。客户端会重连并落到新一代工作进程上。 集成测试覆盖见 `test/test_sse.dft`(分帧、增量投递、心跳、断开/`:on-close`、GC 拆除、校验)。 ## 快速参考 | 形式 | 用途 | |---|---| | `[http/sse $res ?opts]` | 打开流(`:retry`、`:keep-alive`、`:status`、`:headers`、`:on-close`) | | `[http/sse-send $stream $data ?opts]` | 发送事件(`:event`、`:id`);true 或 `%Err` | | `[http/sse-comment $stream $text]` | 注释行 / 手动保活 | | `[http/sse-close $stream]` | 终止块 + 关闭 | 另见 HTTP 服务器 和 应用服务器。