@songfreedom/sse-client 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 songfreedom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,295 @@
1
- # Temporary Holding Version
1
+ # @songfreedom/sse-client
2
+
3
+ 零依赖的 SSE 客户端:核心只有 `fetch` + `ReadableStream` + 自研帧解析,Vue 适配层单独入口。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add @songfreedom/sse-client
9
+ ```
10
+
11
+ Vue 入口需要项目里已有 `vue`(可选 peer,非 Vue 项目不会被强制拉取):
12
+
13
+ ```bash
14
+ pnpm add @songfreedom/sse-client vue
15
+ ```
16
+
17
+ ```ts
18
+ import { SSEClient, createBatcher } from "@songfreedom/sse-client"; // 框架无关
19
+ import { useSSE } from "@songfreedom/sse-client/vue"; // Vue 入口
20
+ ```
21
+
22
+ 零运行时依赖;核心只有 `fetch` + `ReadableStream` + 自研帧解析。
23
+
24
+ ## 为什么不用现成库
25
+
26
+ | 方案 | 问题 |
27
+ | --- | --- |
28
+ | 原生 `EventSource` | 不能自定义请求头(鉴权 token 无处安放)、只能 GET、不能带 body、重连策略不可控 |
29
+ | `@microsoft/fetch-event-source` | 已归档;`onopen` 里非 2xx 必须手动抛错,否则静默死循环重连;重连间隔固定;无 `Last-Event-ID` 持久化 |
30
+ | axios | 浏览器端读不到流式响应(`responseType: "stream"` 仅 Node 有效),`onDownloadProgress` 拼字符串会在多字节字符边界处截断 |
31
+
32
+ ## 设计分层
33
+
34
+ ```
35
+ parser.ts 纯函数:字节流 → 事件(data/event/id/retry、CRLF、注释行、跨块半行)
36
+ client.ts 连接生命周期:建连、重连退避、空闲超时、主动轮换、事件路由、多订阅者
37
+ share.ts 按 key 共享连接(引用计数)
38
+ vue.ts useSSE:状态/事件缓存 + 作用域自动清理(peer: vue,可选)
39
+ ```
40
+
41
+ 库本身不知道 token、不知道 store、不弹 toast、不打日志(除回调抛错的兜底)。业务侧的鉴权头由调用方通过 `headers` 注入。
42
+
43
+ ## 快速开始
44
+
45
+ ```ts
46
+ import { SSEClient } from "@songfreedom/sse-client";
47
+
48
+ interface AlarmEvent { ALARM: { level: "high" | "low"; deviceId: string } }
49
+
50
+ const client = new SSEClient<AlarmEvent>({
51
+ url: "/api/push/alarm/subscribe",
52
+ body: { topics: ["alarm"] },
53
+ headers: () => ({ token: localStorage.getItem("token") ?? "" }), // 每次重连都重新取
54
+ parse: (raw) => JSON.parse(raw),
55
+ idleTimeout: 45_000, // 服务端心跳 15s 时取 3 倍
56
+ });
57
+
58
+ // 按事件名订阅,一条连接服务多个业务方
59
+ const off = client.on("ALARM", (alarm) => console.log(alarm.level));
60
+ client.on("*", (data, message) => console.log(message.event, data));
61
+
62
+ // 组件卸载时
63
+ off();
64
+ client.close();
65
+ ```
66
+
67
+ ### Vue
68
+
69
+ ```ts
70
+ import { useSSE } from "@songfreedom/sse-client/vue";
71
+
72
+ const { status, lastEvent, on } = useSSE<{ ALARM: AlarmEvent }>({
73
+ url: "/api/push/alarm/subscribe",
74
+ body: { topics: ["alarm"] },
75
+ parse: (raw) => JSON.parse(raw),
76
+ });
77
+ on("ALARM", (alarm) => { /* 组件卸载时自动退订 */ });
78
+ ```
79
+
80
+ ## API
81
+
82
+ ### `new SSEClient(options)`
83
+
84
+ | 选项 | 说明 |
85
+ | --- | --- |
86
+ | `url` / `method` / `body` | 请求目标,默认 `POST` + JSON body;body 支持对象、字符串、Blob、FormData |
87
+ | `headers` | `HeadersInit` 或返回它的函数;传函数则每次(重)连重新取,token 刷新后自动生效 |
88
+ | `parse` | `data` 解析函数,默认原样字符串 |
89
+ | `idleTimeout` | 多久没收到任何字节就重连,`0` 关闭(服务端有心跳时建议设成心跳间隔的 3 倍) |
90
+ | `rotateInterval` | 主动轮换间隔,`0` 关闭(见下) |
91
+ | `retry` | 自定义重试策略 `(ctx) => 延迟ms \| false`,覆盖内置退避 |
92
+ | `maxRetry` / `retryBaseDelay` / `retryMaxDelay` | 内置退避参数(初始化 1s,上限 30s,±20% 抖动,下限 200ms) |
93
+ | `reconnectOnEnd` | 服务端正常结束流后是否重连,默认 `true` |
94
+ | `strictContentType` | 强校验响应必须是 `text/event-stream`,默认 `true`;可拦住「网关返回 200 HTML」 |
95
+ | `maxBufferSize` | 单条消息缓冲上限(字符数),默认 1MB,超过直接判定协议错误 |
96
+ | `endSignal` | 流结束哨兵,data 等于该值(如 `[DONE]`)时按正常结束收尾且不重连 |
97
+ | `events` | 事件白名单,只投递这些事件名;缺省全投 |
98
+ | `pauseWhenHidden` | 页面隐藏时挂起、回前台自动重连,默认 `false`;WebView / 移动端建议开 |
99
+ | `fetch` | 注入 fetch 实现,便于 SSR / 测试 / 自定义 transport |
100
+ | `onHandlerError` | 回调抛错兜底,默认 `console.error`(不会中断连接) |
101
+
102
+ 实例方法:`on(event, handler)`(返回退订函数)、`once(event, handler)`、`on("*")`、`onMessage`、`onStatus`、`onError`、`onOpen`、`onClose`、`start`、`close`、`restart`;getter:`status`、`closed`、`attempts`、`lastEventId`、`done`。
103
+
104
+ 消息结构:`{ id, event, data, raw, receivedAt }`,`receivedAt` 便于做本地过期判断。
105
+
106
+ 状态流转:`idle → connecting → open → reconnecting → closed`,`connecting/open/closed` 可直接绑 UI;断网或切后台挂起时回到 `idle`。
107
+
108
+ ### 断网与前后台(默认行为)
109
+
110
+ - **断网**(`offline` 事件):立即中断当前连接并停止重连,不再打服务端;`online` 后立刻重连(带 `Last-Event-ID`)。避免离线期间的重试风暴。
111
+ - **切后台**:默认不影响连接;`pauseWhenHidden: true` 时页面隐藏即挂起、回到前台重连。WebView 后台会被系统挂起,留着连接只会变僵尸,移动端建议开启。
112
+ - 主动 `close()` 会解绑这些全局监听,不留残留。
113
+
114
+ ### 错误分类
115
+
116
+ `SSEError.kind` 用于日志聚合和策略分支:
117
+
118
+ | kind | 含义 | 默认是否重连 |
119
+ | --- | --- | --- |
120
+ | `http` | 非 2xx(`status` 有值) | 408/425/429/5xx 重连,其余终止 |
121
+ | `network` | DNS / 连接重置等 | 是 |
122
+ | `heartbeat` | 静默超时,连接疑似僵死 | 是 |
123
+ | `protocol` | content-type 不符、缓冲溢出、body 不可读 | 否 |
124
+ | `aborted` | 被中断(切后台、断网、主动关闭) | 否 |
125
+ | `retry-exhausted` | 重试次数耗尽 | 否 |
126
+
127
+ ### 重试策略(扩展点)
128
+
129
+ 内置策略对 401/403/404 等 4xx 直接终止,只重试 408/425/429/5xx 与网络异常。需要「401 刷新 token 再重试」这类逻辑时:
130
+
131
+ ```ts
132
+ let refreshed = false;
133
+ const client = new SSEClient({
134
+ url,
135
+ // 支持 async:401 时先真正刷新 token,再决定是否重连
136
+ retry: async ({ error, attempt }) => {
137
+ if (error?.status === 401 && !refreshed) {
138
+ await refreshToken();
139
+ refreshed = true;
140
+ return 0; // 立即重试
141
+ }
142
+ return attempt > 5 ? false : Math.min(30_000, 1000 * 2 ** attempt);
143
+ },
144
+ });
145
+ ```
146
+
147
+ ## 与 AI SDK 的对照
148
+
149
+ AI SDK([Stream Protocols](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol))的流是「一条 SSE 承载多种 `data.type` part」,客户端按 part 建状态机;业务推送是「按 `event:` 路由 + 业务归并」。目标不同,但有几处可以直接借:
150
+
151
+ | AI SDK 的做法 | 这里怎么处理 |
152
+ | --- | --- |
153
+ | `[DONE]` 终止哨兵 | 提供 `endSignal: "[DONE]"`。差异:AI SDK 只是**丢弃**这条、等连接关闭;我们**主动结束**,避免 `reconnectOnEnd` 下反复重连 |
154
+ | 每条 data 过 schema 校验,失败不抛而是作为值下传 | 用 `parse` 抛错即可:只丢这一条并回调 `onHandlerError`,流不中断;不引入 zod |
155
+ | `pipeThrough` 组合式(`TextDecoderStream` → `EventSourceParserStream` → `TransformStream`) | 提供 `createSSEMessageStream()`,用于自定义 transport / Service Worker / Edge |
156
+ | `retryWithExponentialBackoff`:`shouldRetry` 可 async、延迟可插拔、耗尽时聚合 errors | `RetryPolicy` 支持 async 与完全自定义延迟;错误不聚合,靠 `SSEError.kind` 区分 |
157
+ | `text-start/delta/end` 按 id 合并成 part | 推送不是 delta 协议;等价的「按 key 合并取最新」用 `createBatcher()` |
158
+ | `useChat` 的 `status` 状态机 + `onData` 瞬时数据 | 已有 `status` 枚举 + 事件路由 |
159
+ | transport 抽象(`prepareSendMessagesRequest` 等) | `fetch` 注入 + `headers` 函数足够 |
160
+ | `smoothStream`、断点重放、message parts 持久化 | 未采纳——属于聊天产品语义,不是连接层能力 |
161
+
162
+ ### 连接共享
163
+
164
+ 同一份订阅被多个组件消费时,只建一条连接:
165
+
166
+ ```ts
167
+ const { client, release } = acquireSSEClient("alarm", () => new SSEClient({ url, body }));
168
+ onScopeDispose(release); // 最后一个引用释放时自动 close
169
+ ```
170
+
171
+ ## 数据层放哪
172
+
173
+ 库只负责连接,**不持有业务数据**。`meos-control-web` 的做法可以拿来对照:
174
+
175
+ | 维度 | meos-control-web | 本库 + 应用层建议 |
176
+ | --- | --- | --- |
177
+ | 连接 | 每个组件 `new SSEClient`,N 个组件 = N 条连接 | `acquireSSEClient(key)` 引用计数,同 key 只有一条 |
178
+ | 数据 | 模块级单例 `DockSingleton`(内部一个全局 `ref`,按 objectId 归并) | pinia store,按业务域拆分 |
179
+ | 过期 | 单例 `IotExpireManager` + 15s 轮询 + `expire` 事件 | 用消息上的 `receivedAt` 在 getter 里算,或 store 内自己的定时器 |
180
+ | 合并 | `useResponsiveDockSSEHandle` 里 300ms 窗口,按 `objectId::infoCode` 去重取最新 | `createBatcher()` |
181
+ | 生命周期 | `onMounted` 注册、`onBeforeUnmount` 反注册,手动配对 | `onScopeDispose` + 引用计数自动配对 |
182
+
183
+ **值得借的三点**
184
+
185
+ 1. 按业务 key 增量合并(`Object.assign` 覆盖字段),避免整对象替换导致组件全量重渲染。
186
+ 2. 合并窗口(300ms)——高频推送下这是必须的,否则每次推都是「写库 → 触发渲染」。
187
+ 3. 数据过期标记:推送类数据一定有「多久没更新就算失效」的语义,UI 要能显示「暂无数据」而不是永远显示上次的值。
188
+
189
+ **不要照搬的四点**
190
+
191
+ 1. 全局单例当数据总线:qiankun 子应用卸载后模块级 `ref` 还在,重新挂载会带着上次的脏数据;而且没有引用计数,谁都能写、没人负责清。
192
+ 2. `window.iotHandle = ...` 这类调试钩子——应该由调试工具或 devtools 提供。
193
+ 3. 模块导入即启动定时器(`IotExpireManager.getInstance()` 在 import 时就 `startExpireCheck()`),副作用不可控。
194
+ 4. 每组件一条连接:HTTP/1.1 同域只有 6 个并发,推送通道很容易把其他接口挤排队。
195
+
196
+ 推荐的组合是 pinia(数据域)+ 本库(连接)+ 消费者引用计数(谁用谁 retain):
197
+
198
+ ```ts
199
+ // stores/alarm.ts
200
+ export const useAlarmStore = defineStore("alarm", () => {
201
+ const items = shallowRef<Map<string, Alarm>>(new Map());
202
+
203
+ const batcher = createBatcher<string, Alarm>({
204
+ key: (alarm) => alarm.id,
205
+ interval: 300,
206
+ onFlush: (list) => {
207
+ const next = new Map(items.value);
208
+ list.forEach((alarm) => next.set(alarm.id, alarm));
209
+ items.value = next; // 替换引用,shallowRef 才会触发更新
210
+ },
211
+ });
212
+
213
+ const client = new SSEClient<{ ALARM: Alarm }>({ url, body, autoStart: false });
214
+ client.on("ALARM", (alarm) => batcher.push(alarm));
215
+
216
+ let refs = 0;
217
+ function retain() {
218
+ refs += 1;
219
+ if (refs === 1) client.start();
220
+ }
221
+ function release() {
222
+ refs = Math.max(0, refs - 1);
223
+ if (refs === 0) {
224
+ batcher.dispose(); // 冲刷窗口内剩余数据
225
+ client.close();
226
+ }
227
+ }
228
+
229
+ return { items, retain, release };
230
+ });
231
+
232
+ // 组件里
233
+ const alarmStore = useAlarmStore();
234
+ alarmStore.retain();
235
+ onScopeDispose(() => alarmStore.release());
236
+ ```
237
+
238
+ 如果同一条订阅要跨多个 store / 模块共享,把 `new SSEClient` 换成 `acquireSSEClient(key, factory)`,它的引用计数语义相同、且按 key 全局唯一。
239
+
240
+ ## 服务端与网关约定
241
+
242
+ - 响应头必须是 `Content-Type: text/event-stream`(可能带 `;charset=UTF-8`,校验按前缀匹配),并且 **不能被代理层缓冲**:`proxy_buffering off; proxy_cache off;`,该路径不要开 gzip。
243
+ - 建议每 15s 发一次 `: ping\n\n` 心跳,否则多数云 LB 的 60s idle 会断开;配合 `idleTimeout` 使用。
244
+ - 事件 id 建议单调递增(或至少唯一)。重连时客户端会带 `Last-Event-ID`,服务端按语义只补发其后的消息;客户端不做去重。
245
+ - 事件类型通过 `event:` 字段下发,客户端按该字段路由。
246
+
247
+ ## 主动轮换 vs 断线重连
248
+
249
+ 轮换是为了防止「TCP 活着、心跳还在,但服务端订阅已失效」的假活连接:到 `rotateInterval` 后客户端主动断开并立即重连(保留 `lastEventId`,不走退避)。
250
+
251
+ 对比双连接 overlap 方案(新连接 open 后再切旧连接):overlap 能消除重连窗口,但要求服务端按连接推送而非按事件流推送,否则同一事件会被两条连接各推一次;本实现选择「断开 + `Last-Event-ID` 续传」,实现简单且不产生重复推送,代价是需要服务端支持续传语义。
252
+
253
+ ## 开发
254
+
255
+ ```bash
256
+ pnpm --filter @songfreedom/sse-client test
257
+ pnpm --filter @songfreedom/sse-client typecheck
258
+ pnpm --filter @songfreedom/sse-client lint
259
+ ```
260
+
261
+ ## 构建与发布
262
+
263
+ **双入口设计**,开发时零构建、发布时只发产物:
264
+
265
+ | 场景 | 走哪里 | 原因 |
266
+ | --- | --- | --- |
267
+ | 仓库内(`apps/web` 等) | `exports` → `./src/*.ts` | Vite 直接编译源码,改库代码立即 HMR |
268
+ | npm 包 | `dist/` + `dist/package.json` | 消费方拿到 JS + `.d.ts`,不需要编译 TS |
269
+
270
+ `pnpm build` 做两件事:`tsc` 把 `src` 编译成 ESM + `.d.ts` + sourcemap 到 `dist/`,然后由 `scripts/build-package.mjs` 生成 `dist/package.json` 并拷入 README。
271
+
272
+ 发布到 npmjs(`publishConfig` 已指向 `registry.npmjs.org` + `access: public`):
273
+
274
+ ```bash
275
+ npm login --registry https://registry.npmjs.org # 只需一次
276
+ pnpm --filter @songfreedom/sse-client publish:npm # build + publish
277
+ ```
278
+
279
+ 只想先看打包内容不发布:
280
+
281
+ ```bash
282
+ pnpm --filter @songfreedom/sse-client publish --dry-run --no-git-checks
283
+ ```
284
+
285
+ 约定:
286
+
287
+ - 发布走 `publishConfig.directory: dist`,所以**必须先 build**(`publish:npm` 已包含)。
288
+ - `vue` 是 **optional peer**,非 Vue 项目安装不会被迫拉 vue。
289
+ - `sideEffects: false`,消费方可 tree-shaking。
290
+ - 版本号每次发布必须递增,npm 不允许覆盖已有版本。
291
+
292
+ ## License
293
+
294
+ [MIT](./LICENSE)
2
295
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
package/batch.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 高频推送的合并窗口。
3
+ *
4
+ * 业务推送常见「同一对象在几百毫秒内连推多次」的场景(IOT 点位、设备状态),
5
+ * 逐条写库会引发渲染风暴。这里按业务 key 合并、保留最新值,在一个时间窗后统一回调。
6
+ *
7
+ * ```ts
8
+ * const batcher = createBatcher<number, Point>({
9
+ * key: (p) => p.pointId,
10
+ * interval: 300,
11
+ * onFlush: (points) => store.applyPoints(points),
12
+ * });
13
+ * client.on("IOT", (p) => batcher.push(p));
14
+ * ```
15
+ */
16
+ export interface BatcherOptions<K, V> {
17
+ /** 合并用的业务键,同一 key 只保留最后一次的值 */
18
+ key: (value: V) => K;
19
+ /** 合并窗口(毫秒),默认 300 */
20
+ interval?: number;
21
+ /** 窗口结束时的回调,按首次出现的顺序给出每个 key 的最新值 */
22
+ onFlush: (values: V[]) => void;
23
+ }
24
+ export interface Batcher<V> {
25
+ /** 写入一条数据;同一 key 覆盖旧值 */
26
+ push: (value: V) => void;
27
+ /** 立即冲刷并取消本次窗口 */
28
+ flush: () => void;
29
+ /** 丢弃尚未冲刷的数据 */
30
+ clear: () => void;
31
+ /** 冲刷并释放定时器(组件卸载时调用) */
32
+ dispose: () => void;
33
+ /** 待冲刷的数据条数 */
34
+ readonly pending: number;
35
+ }
36
+ export declare function createBatcher<K, V>(options: BatcherOptions<K, V>): Batcher<V>;
37
+ //# sourceMappingURL=batch.d.ts.map
package/batch.d.ts.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch.d.ts","sourceRoot":"","sources":["../src/batch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,EAAE,CAAC;IAClC,+BAA+B;IAC/B,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC;IACrB,sBAAsB;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,OAAO,CAAC,CAAC;IACxB,yBAAyB;IACzB,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IACzB,kBAAkB;IAClB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,gBAAgB;IAChB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,wBAAwB;IACxB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,eAAe;IACf,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4C7E"}
package/batch.js ADDED
@@ -0,0 +1,44 @@
1
+ export function createBatcher(options) {
2
+ const interval = options.interval ?? 300;
3
+ const buffer = new Map();
4
+ let timer = null;
5
+ const flush = () => {
6
+ if (timer) {
7
+ clearTimeout(timer);
8
+ timer = null;
9
+ }
10
+ if (!buffer.size) {
11
+ return;
12
+ }
13
+ const values = [...buffer.values()];
14
+ buffer.clear();
15
+ options.onFlush(values);
16
+ };
17
+ return {
18
+ push(value) {
19
+ buffer.set(options.key(value), value);
20
+ if (timer) {
21
+ return;
22
+ }
23
+ timer = setTimeout(() => {
24
+ timer = null;
25
+ flush();
26
+ }, interval);
27
+ },
28
+ flush,
29
+ clear() {
30
+ if (timer) {
31
+ clearTimeout(timer);
32
+ timer = null;
33
+ }
34
+ buffer.clear();
35
+ },
36
+ dispose() {
37
+ flush();
38
+ },
39
+ get pending() {
40
+ return buffer.size;
41
+ },
42
+ };
43
+ }
44
+ //# sourceMappingURL=batch.js.map
package/batch.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch.js","sourceRoot":"","sources":["../src/batch.ts"],"names":[],"mappings":"AAqCA,MAAM,UAAU,aAAa,CAAO,OAA6B;IAC/D,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,GAAG,CAAC;IACzC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAQ,CAAC;IAC/B,IAAI,KAAK,GAAyC,IAAI,CAAC;IAEvD,MAAM,KAAK,GAAG,GAAG,EAAE;QACjB,IAAI,KAAK,EAAE,CAAC;YACV,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,KAAK,GAAG,IAAI,CAAC;QACf,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YACjB,OAAO;QACT,CAAC;QACD,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;QACpC,MAAM,CAAC,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC,CAAC;IAEF,OAAO;QACL,IAAI,CAAC,KAAQ;YACX,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC;YACtC,IAAI,KAAK,EAAE,CAAC;gBACV,OAAO;YACT,CAAC;YACD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBACtB,KAAK,GAAG,IAAI,CAAC;gBACb,KAAK,EAAE,CAAC;YACV,CAAC,EAAE,QAAQ,CAAC,CAAC;QACf,CAAC;QACD,KAAK;QACL,KAAK;YACH,IAAI,KAAK,EAAE,CAAC;gBACV,YAAY,CAAC,KAAK,CAAC,CAAC;gBACpB,KAAK,GAAG,IAAI,CAAC;YACf,CAAC;YACD,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,CAAC;QACD,OAAO;YACL,KAAK,EAAE,CAAC;QACV,CAAC;QACD,IAAI,OAAO;YACT,OAAO,MAAM,CAAC,IAAI,CAAC;QACrB,CAAC;KACF,CAAC;AACJ,CAAC"}
package/client.d.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { SSEError } from "./errors.js";
2
+ import type { SSECloseReason, SSEEventMap, SSEMessage, SSEStatus, Unsubscribe } from "./types.js";
3
+ /** 与 `fetch` 同形,便于注入自定义 transport(SSR、测试、带签名的请求) */
4
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
5
+ /** 重试决策上下文 */
6
+ export interface RetryContext {
7
+ /** 第几次重试,从 1 开始 */
8
+ attempt: number;
9
+ /** 触发重连的原因;服务端正常结束流时为 undefined */
10
+ error?: SSEError;
11
+ /** 本次连接存活时长(毫秒) */
12
+ elapsed: number;
13
+ }
14
+ /**
15
+ * 自定义重试策略:返回下次重连延迟(毫秒),返回 `false` 表示终止。
16
+ * 支持 async,便于「401 → 先刷新 token → 再重试」这类需要等待的决策。
17
+ * 不传则使用内置的「指数退避 + 抖动 + 最大次数」。
18
+ */
19
+ export type RetryPolicy = (context: RetryContext) => number | false | Promise<number | false>;
20
+ /** 客户端配置 */
21
+ export interface SSEClientOptions {
22
+ /** 请求地址(需自行带上网关前缀) */
23
+ url: string;
24
+ /** 请求方法,业务推送一般用 POST */
25
+ method?: "GET" | "POST";
26
+ /** 请求体,非 GET 时按 JSON 序列化;也支持直接传字符串 / Blob / FormData */
27
+ body?: unknown;
28
+ /**
29
+ * 额外请求头。传函数则每次(重)连都重新取,
30
+ * 便于拿到最新 token,因此推荐 `() => getCommonHeaders()`。
31
+ */
32
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
33
+ /** data 解析,例如 `(raw) => JSON.parse(raw)`;不传则 data 为原始字符串 */
34
+ parse?: (raw: string) => unknown;
35
+ /** 空闲超时(毫秒):多久没收到任何字节就断开重连,0 表示关闭。建议取心跳间隔的 2~3 倍 */
36
+ idleTimeout?: number;
37
+ /** 主动轮换间隔(毫秒):到点后主动重连,防止连接假活,0 表示关闭 */
38
+ rotateInterval?: number;
39
+ /** 自定义重试策略,覆盖内置退避 */
40
+ retry?: RetryPolicy;
41
+ /** 最大重试次数,默认无限(仅在未传 `retry` 时生效) */
42
+ maxRetry?: number;
43
+ /** 首次重连延迟(毫秒),默认 1000 */
44
+ retryBaseDelay?: number;
45
+ /** 重连延迟上限(毫秒),默认 30000 */
46
+ retryMaxDelay?: number;
47
+ /** 服务端正常结束流后是否自动重连,默认 true */
48
+ reconnectOnEnd?: boolean;
49
+ /** 连接存活超过该时长即视为稳定,重置重试计数,默认 10000 */
50
+ stableAfter?: number;
51
+ /**
52
+ * 强校验响应 `content-type` 必须是 `text/event-stream`,默认 true。
53
+ * 能拦住「网关/鉴权页返回 200 HTML」这类静默失败;后端不规范时可关闭。
54
+ */
55
+ strictContentType?: boolean;
56
+ /** 单条消息缓冲上限(字符数),默认 1MB,防止异常流撑爆内存 */
57
+ maxBufferSize?: number;
58
+ /**
59
+ * 流结束哨兵:data 等于该值时视为正常结束(OpenAI / AI SDK 风格用 `[DONE]`),
60
+ * 触发后按「正常结束」收尾且不再重连。默认不启用。
61
+ */
62
+ endSignal?: string;
63
+ /** 事件白名单:只投递这些事件名,缺省全部投递 */
64
+ events?: string[];
65
+ /**
66
+ * 页面隐藏时挂起连接、回到前台自动重连,默认 false。
67
+ * WebView / 移动端建议开启(后台会被系统挂起,留着连接只会变僵尸)。
68
+ */
69
+ pauseWhenHidden?: boolean;
70
+ /** 是否在构造后立即连接,默认 true */
71
+ autoStart?: boolean;
72
+ /** 注入 fetch 实现,默认使用全局 fetch */
73
+ fetch?: FetchLike;
74
+ /** 事件处理函数抛错时的兜底,默认打到控制台 */
75
+ onHandlerError?: (error: unknown) => void;
76
+ /** 连接建立(已收到响应头)时触发,等价于 `client.onOpen()` */
77
+ onOpen?: (response: Response) => void;
78
+ /** 所有消息都会触发,等价于 `client.onMessage()` */
79
+ onMessage?: (message: SSEMessage) => void;
80
+ /** 状态变化,等价于 `client.onStatus()` */
81
+ onStatus?: (status: SSEStatus) => void;
82
+ /** 每次失败触发,等价于 `client.onError()` */
83
+ onError?: (error: SSEError, attempt: number) => void;
84
+ /** 最终关闭时触发一次,等价于 `client.onClose()` */
85
+ onClose?: (reason: SSECloseReason, error?: SSEError) => void;
86
+ }
87
+ /**
88
+ * SSE 客户端。
89
+ *
90
+ * - 基于 `fetch` + `ReadableStream`,支持自定义请求头与 POST body
91
+ * - 断线自动重连(指数退避 + 抖动),401 等不可重试错误直接终止
92
+ * - 用 `Last-Event-ID` 做断点续传
93
+ * - 按事件名路由到多个订阅者,一条连接服务多个组件
94
+ * - 空闲看门狗、主动轮换、断网/切后台挂起、连接共享(见 `acquireSSEClient`)
95
+ */
96
+ export declare class SSEClient<E extends object = SSEEventMap> {
97
+ private readonly options;
98
+ private readonly parser;
99
+ private readonly eventFilter;
100
+ private state;
101
+ private finished;
102
+ private running;
103
+ private suspended;
104
+ private attempt;
105
+ private lastEventIdValue;
106
+ private serverRetryDelay;
107
+ private idleTimedOut;
108
+ private rotating;
109
+ private idleTimer;
110
+ private rotateTimer;
111
+ private cancelController;
112
+ private attemptController;
113
+ private runPromise;
114
+ private resumeWaiter;
115
+ private lifecycleBound;
116
+ private onVisibilityChange?;
117
+ private onOffline?;
118
+ private onOnline?;
119
+ private readonly messageHandlers;
120
+ private readonly eventHandlers;
121
+ private readonly statusHandlers;
122
+ private readonly errorHandlers;
123
+ private readonly openHandlers;
124
+ private readonly closeHandlers;
125
+ constructor(options: SSEClientOptions);
126
+ /** 当前状态 */
127
+ get status(): SSEStatus;
128
+ /** 是否已最终关闭(不会再重连) */
129
+ get closed(): boolean;
130
+ /** 连续重试次数,连接稳定后归零 */
131
+ get attempts(): number;
132
+ /** 最近一次事件 id,重连时通过 Last-Event-ID 带给服务端 */
133
+ get lastEventId(): string;
134
+ /** 连接终止(无论正常关闭还是放弃重连)后 resolve,不会 reject */
135
+ get done(): Promise<void>;
136
+ /** 建立连接;已连接或已挂起时调用无副作用 */
137
+ start(): this;
138
+ /** 主动关闭,不再重连 */
139
+ close(): void;
140
+ /** 关闭后重新连接,保留 lastEventId 以便续传 */
141
+ restart(): this;
142
+ /** 订阅指定事件名的消息,返回取消订阅函数 */
143
+ on<K extends Extract<keyof E, string>>(event: K, handler: (data: E[K], message: SSEMessage) => void): Unsubscribe;
144
+ /** 订阅所有消息(event 为 "*") */
145
+ on(event: "*", handler: (data: unknown, message: SSEMessage) => void): Unsubscribe;
146
+ /** 只订阅一次,触发后自动退订 */
147
+ once<K extends Extract<keyof E, string>>(event: K, handler: (data: E[K], message: SSEMessage) => void): Unsubscribe;
148
+ /** 订阅所有消息(不区分事件名) */
149
+ onMessage(handler: (message: SSEMessage) => void): Unsubscribe;
150
+ onStatus(handler: (status: SSEStatus) => void): Unsubscribe;
151
+ onError(handler: (error: SSEError, attempt: number) => void): Unsubscribe;
152
+ onOpen(handler: (response: Response) => void): Unsubscribe;
153
+ onClose(handler: (reason: SSECloseReason, error?: SSEError) => void): Unsubscribe;
154
+ private get retryBaseDelay();
155
+ private get retryMaxDelay();
156
+ private get maxRetry();
157
+ private get stableAfter();
158
+ private get reconnectOnEnd();
159
+ private reset;
160
+ private connect;
161
+ /** 建立一次连接并持续读取,直到流结束(失败时抛错) */
162
+ private openOnce;
163
+ private resolveHeaders;
164
+ private handleParsedEvent;
165
+ private dispatch;
166
+ private emit;
167
+ private safeCall;
168
+ private reportHandlerError;
169
+ private decideDelay;
170
+ /** 重连延迟:服务端 `retry:` 优先,否则指数退避 + ±20% 抖动 */
171
+ private nextDelay;
172
+ private sleep;
173
+ private waitForResume;
174
+ private armIdle;
175
+ private armRotate;
176
+ private clearIdle;
177
+ private clearRotate;
178
+ private clearTimers;
179
+ private setState;
180
+ /** 网络上下线始终启用:断网期间不重连,恢复后立即重连 */
181
+ private bindLifecycle;
182
+ private unbindLifecycle;
183
+ private suspend;
184
+ private resume;
185
+ private finish;
186
+ }
187
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiC,MAAM,aAAa,CAAC;AAOtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElG,oDAAoD;AACpD,MAAM,MAAM,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEhF,cAAc;AACd,MAAM,WAAW,YAAY;IAC3B,mBAAmB;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,mBAAmB;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,CACxB,OAAO,EAAE,YAAY,KAClB,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC;AAE9C,YAAY;AACZ,MAAM,WAAW,gBAAgB;IAC/B,sBAAsB;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,wBAAwB;IACxB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,wDAAwD;IACxD,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC;IACnE,4DAA4D;IAC5D,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACjC,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qBAAqB;IACrB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yBAAyB;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,0BAA0B;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8BAA8B;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,qCAAqC;IACrC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,yBAAyB;IACzB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,2BAA2B;IAC3B,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,4CAA4C;IAC5C,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IACtC,wCAAwC;IACxC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;IAC1C,mCAAmC;IACnC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC;IACvC,oCAAoC;IACpC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACrD,uCAAuC;IACvC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,QAAQ,KAAK,IAAI,CAAC;CAC9D;AAMD;;;;;;;;GAQG;AACH,qBAAa,SAAS,CAAC,CAAC,SAAS,MAAM,GAAG,WAAW;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAY;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IAEjD,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAqC;IACtD,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,gBAAgB,CAAM;IAC9B,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,SAAS,CAA8C;IAC/D,OAAO,CAAC,WAAW,CAA8C;IACjE,OAAO,CAAC,gBAAgB,CAAyB;IACjD,OAAO,CAAC,iBAAiB,CAAgC;IACzD,OAAO,CAAC,UAAU,CAA8B;IAChD,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,kBAAkB,CAAC,CAAa;IACxC,OAAO,CAAC,SAAS,CAAC,CAAa;IAC/B,OAAO,CAAC,QAAQ,CAAC,CAAa;IAE9B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA4C;IAC5E,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwE;IACtG,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;IACzE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyD;IACvF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA2C;IACxE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAiE;gBAEnF,OAAO,EAAE,gBAAgB;IAuBrC,WAAW;IACX,IAAI,MAAM,IAAI,SAAS,CAEtB;IAED,qBAAqB;IACrB,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,qBAAqB;IACrB,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,0CAA0C;IAC1C,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,4CAA4C;IAC5C,IAAI,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAExB;IAED,0BAA0B;IAC1B,KAAK,IAAI,IAAI;IA8Bb,gBAAgB;IAChB,KAAK,IAAI,IAAI;IAOb,kCAAkC;IAClC,OAAO,IAAI,IAAI;IAKf,0BAA0B;IAC1B,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,EACnC,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,UAAU,KAAK,IAAI,GACjD,WAAW;IACd,0BAA0B;IAC1B,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,KAAK,IAAI,GAAG,WAAW;IAoBlF,oBAAoB;IACpB,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,EACrC,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,UAAU,KAAK,IAAI,GACjD,WAAW;IAQd,qBAAqB;IACrB,SAAS,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,GAAG,WAAW;IAI9D,QAAQ,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,GAAG,WAAW;IAI3D,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,WAAW;IAIzE,MAAM,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,GAAG,WAAW;IAI1D,OAAO,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,QAAQ,KAAK,IAAI,GAAG,WAAW;IAQjF,OAAO,KAAK,cAAc,GAEzB;IAED,OAAO,KAAK,aAAa,GAExB;IAED,OAAO,KAAK,QAAQ,GAEnB;IAED,OAAO,KAAK,WAAW,GAEtB;IAED,OAAO,KAAK,cAAc,GAEzB;IAED,OAAO,CAAC,KAAK;YAaC,OAAO;IA2ErB,+BAA+B;YACjB,QAAQ;YA4ER,cAAc;IAgB5B,OAAO,CAAC,iBAAiB;IAiCzB,OAAO,CAAC,QAAQ;IAmBhB,OAAO,CAAC,IAAI;IAMZ,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,kBAAkB;YAQZ,WAAW;IAazB,4CAA4C;IAC5C,OAAO,CAAC,SAAS;IASjB,OAAO,CAAC,KAAK;IAkBb,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,QAAQ;IAYhB,gCAAgC;IAChC,OAAO,CAAC,aAAa;IAiCrB,OAAO,CAAC,eAAe;IAcvB,OAAO,CAAC,OAAO;IAUf,OAAO,CAAC,MAAM;IAcd,OAAO,CAAC,MAAM;CAef"}