@songfreedom/sse-client 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,295 +1,302 @@
1
1
  # @songfreedom/sse-client
2
2
 
3
- 零依赖的 SSE 客户端:核心只有 `fetch` + `ReadableStream` + 自研帧解析,Vue 适配层单独入口。
3
+ [![npm version](https://img.shields.io/npm/v/%40songfreedom%2Fsse-client)](https://www.npmjs.com/package/@songfreedom/sse-client)
4
+ [![license](https://img.shields.io/npm/l/%40songfreedom%2Fsse-client)](./LICENSE)
5
+ [![minzip size](https://img.shields.io/bundlephobia/minzip/%40songfreedom%2Fsse-client)](https://bundlephobia.com/package/@songfreedom/sse-client)
4
6
 
5
- ## 安装
7
+ A small, dependency-free Server-Sent Events client for the browser.
8
+ `fetch` transport, typed event routing, automatic reconnection with `Last-Event-ID` resume,
9
+ an idle watchdog and shared connections.
6
10
 
7
- ```bash
8
- pnpm add @songfreedom/sse-client
9
- ```
11
+ ```ts
12
+ import { SSEClient } from "@songfreedom/sse-client";
10
13
 
11
- Vue 入口需要项目里已有 `vue`(可选 peer,非 Vue 项目不会被强制拉取):
14
+ const client = new SSEClient({
15
+ url: "/api/push/subscribe",
16
+ body: { topics: ["alarm"] },
17
+ headers: () => ({ authorization: `Bearer ${getToken()}` }),
18
+ parse: (raw) => JSON.parse(raw),
19
+ });
12
20
 
13
- ```bash
14
- pnpm add @songfreedom/sse-client vue
21
+ client.on("ALARM", (alarm) => console.log(alarm.level));
15
22
  ```
16
23
 
17
- ```ts
18
- import { SSEClient, createBatcher } from "@songfreedom/sse-client"; // 框架无关
19
- import { useSSE } from "@songfreedom/sse-client/vue"; // Vue 入口
20
- ```
24
+ ## Why not `EventSource`?
21
25
 
22
- 零运行时依赖;核心只有 `fetch` + `ReadableStream` + 自研帧解析。
26
+ | Option | Problem |
27
+ | --- | --- |
28
+ | Native `EventSource` | Cannot set request headers (no way to send an auth token), GET only, no request body, no control over reconnection |
29
+ | [`@microsoft/fetch-event-source`](https://github.com/Azure/fetch-event-source) | Archived. Non-2xx responses must be rejected by hand in `onopen`, otherwise it silently reconnects forever; fixed retry interval; no `Last-Event-ID` persistence |
30
+ | `axios` | Cannot read a streaming response in the browser (`responseType: "stream"` is Node-only). Concatenating `responseText` in `onDownloadProgress` corrupts multi-byte characters |
23
31
 
24
- ## 为什么不用现成库
32
+ This library is roughly 3 kB gzipped, has no runtime dependencies, and only needs `fetch`
33
+ and `ReadableStream`.
25
34
 
26
- | 方案 | 问题 |
27
- | --- | --- |
28
- | 原生 `EventSource` | 不能自定义请求头(鉴权 token 无处安放)、只能 GET、不能带 body、重连策略不可控 |
29
- | `@microsoft/fetch-event-source` | 已归档;`onopen` 里非 2xx 必须手动抛错,否则静默死循环重连;重连间隔固定;无 `Last-Event-ID` 持久化 |
30
- | axios | 浏览器端读不到流式响应(`responseType: "stream"` 仅 Node 有效),`onDownloadProgress` 拼字符串会在多字节字符边界处截断 |
35
+ ## Features
31
36
 
32
- ## 设计分层
37
+ - **POST + JSON body**, arbitrary headers, and header functions re-evaluated on every reconnect
38
+ - **Reconnects** with exponential backoff and jitter; terminal errors (401/403/404) stop immediately
39
+ - **Resume** with `Last-Event-ID`, so a reconnect can pick up where it left off
40
+ - **Typed event routing**: one connection serves many subscribers, `on()` infers payload types from an event map
41
+ - **Idle watchdog** for streams that go quiet, and **proactive rotation** for connections that stay open but stop receiving data
42
+ - **Offline-aware**: pauses while the network is down, reconnects as soon as it is back
43
+ - **Pluggable retry policy**, including async policies such as "refresh the token, then retry"
44
+ - **Shared connections** by key with reference counting
45
+ - Optional **Vue 3** composable in a separate entry point
33
46
 
47
+ ## Installation
48
+
49
+ ```bash
50
+ pnpm add @songfreedom/sse-client
51
+ # or
52
+ npm install @songfreedom/sse-client
34
53
  ```
35
- parser.ts 纯函数:字节流 → 事件(data/event/id/retry、CRLF、注释行、跨块半行)
36
- client.ts 连接生命周期:建连、重连退避、空闲超时、主动轮换、事件路由、多订阅者
37
- share.ts 按 key 共享连接(引用计数)
38
- vue.ts useSSE:状态/事件缓存 + 作用域自动清理(peer: vue,可选)
54
+
55
+ The Vue entry needs `vue` as a peer dependency (it is optional, so non-Vue users do not install it):
56
+
57
+ ```bash
58
+ pnpm add @songfreedom/sse-client vue
39
59
  ```
40
60
 
41
- 库本身不知道 token、不知道 store、不弹 toast、不打日志(除回调抛错的兜底)。业务侧的鉴权头由调用方通过 `headers` 注入。
61
+ Requires a browser with `fetch` and `ReadableStream` (all evergreen browsers), or Node 18+ for SSR and tests.
62
+
63
+ ## Quick start
42
64
 
43
- ## 快速开始
65
+ ### Framework-agnostic
44
66
 
45
67
  ```ts
46
68
  import { SSEClient } from "@songfreedom/sse-client";
47
69
 
48
- interface AlarmEvent { ALARM: { level: "high" | "low"; deviceId: string } }
70
+ interface Events {
71
+ ALARM: { level: "high" | "low"; deviceId: string };
72
+ IOT: { objectId: string; value: string };
73
+ }
49
74
 
50
- const client = new SSEClient<AlarmEvent>({
51
- url: "/api/push/alarm/subscribe",
52
- body: { topics: ["alarm"] },
53
- headers: () => ({ token: localStorage.getItem("token") ?? "" }), // 每次重连都重新取
75
+ const client = new SSEClient<Events>({
76
+ url: `${gateway}/server-event/sse/subscribe`,
77
+ method: "POST",
78
+ body: [{ subscribeType: "SE", instanceType: "PROJECT" }],
79
+ headers: () => authHeaders(), // re-evaluated before every attempt
54
80
  parse: (raw) => JSON.parse(raw),
55
- idleTimeout: 45_000, // 服务端心跳 15s 时取 3 倍
81
+ idleTimeout: 45_000, // if the server sends a heartbeat every 15s
56
82
  });
57
83
 
58
- // 按事件名订阅,一条连接服务多个业务方
59
- const off = client.on("ALARM", (alarm) => console.log(alarm.level));
60
- client.on("*", (data, message) => console.log(message.event, data));
84
+ const off = client.on("ALARM", (alarm) => {
85
+ // `alarm` is typed as Events["ALARM"]
86
+ console.log(alarm.deviceId);
87
+ });
61
88
 
62
- // 组件卸载时
89
+ // Later
63
90
  off();
64
91
  client.close();
65
92
  ```
66
93
 
67
- ### Vue
94
+ ### Vue 3
68
95
 
69
- ```ts
96
+ ```vue
97
+ <script setup lang="ts">
98
+ import { computed } from "vue";
70
99
  import { useSSE } from "@songfreedom/sse-client/vue";
71
100
 
72
- const { status, lastEvent, on } = useSSE<{ ALARM: AlarmEvent }>({
73
- url: "/api/push/alarm/subscribe",
101
+ interface Events {
102
+ ALARM: { level: "high" | "low"; deviceId: string };
103
+ }
104
+
105
+ const { status, lastEvent, on } = useSSE<Events>({
106
+ url: "/api/push/alarm",
74
107
  body: { topics: ["alarm"] },
75
108
  parse: (raw) => JSON.parse(raw),
76
109
  });
77
- on("ALARM", (alarm) => { /* 组件卸载时自动退订 */ });
110
+
111
+ on("ALARM", (alarm) => {
112
+ // unsubscribed automatically on unmount
113
+ console.log(alarm.level);
114
+ });
115
+
116
+ const connected = computed(() => status.value === "open");
117
+ </script>
78
118
  ```
79
119
 
120
+ `useSSE` adds two things on top of the client: a ring buffer of recent messages
121
+ (`lastEvent`, `events`, `maxEvents`) and scope-bound cleanup — unmounting the component,
122
+ or a qiankun sub-app, closes the connection.
123
+
80
124
  ## API
81
125
 
82
126
  ### `new SSEClient(options)`
83
127
 
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`。
128
+ | Option | Type | Default | Description |
129
+ | --- | --- | --- | --- |
130
+ | `url` | `string` | — | Request URL, including your gateway prefix |
131
+ | `method` | `"GET" \| "POST"` | `"POST"` | HTTP method |
132
+ | `body` | `unknown` | — | Serialized as JSON; strings, `Blob` and `FormData` are sent as-is |
133
+ | `headers` | `HeadersInit \| () => HeadersInit \| Promise<HeadersInit>` | — | Pass a function to recompute headers on every (re)connect |
134
+ | `parse` | `(raw: string) => unknown` | identity | Payload decoder |
135
+ | `idleTimeout` | `number` | `0` | Reconnect when no bytes arrive for this long. Set it to ~3× the heartbeat interval |
136
+ | `rotateInterval` | `number` | `0` | Reconnect on a schedule, even if the connection looks healthy |
137
+ | `retry` | `(ctx) => number \| false \| Promise<number \| false>` | — | Custom retry policy, overrides the built-in backoff |
138
+ | `maxRetry` | `number` | `Infinity` | Retry limit (only when `retry` is omitted) |
139
+ | `retryBaseDelay` | `number` | `1000` | Delay before the first retry (ms) |
140
+ | `retryMaxDelay` | `number` | `30000` | Upper bound for backoff (ms) |
141
+ | `reconnectOnEnd` | `boolean` | `true` | Reconnect when the server ends the stream normally |
142
+ | `stableAfter` | `number` | `10000` | An attempt that lived this long resets the retry counter |
143
+ | `strictContentType` | `boolean` | `true` | Require `content-type: text/event-stream` |
144
+ | `maxBufferSize` | `number` | `1MB` | Cap for a single message buffer (characters) |
145
+ | `endSignal` | `string` | — | Treat this `data` value as end-of-stream, e.g. `"[DONE]"` |
146
+ | `events` | `string[]` | — | Allow-list of event names |
147
+ | `pauseWhenHidden` | `boolean` | `false` | Suspend while the page is hidden, reconnect when visible |
148
+ | `autoStart` | `boolean` | `true` | Connect in the constructor |
149
+ | `fetch` | `FetchLike` | global `fetch` | Custom transport |
150
+ | `onHandlerError` | `(error) => void` | `console.error` | Fallback when a callback throws |
151
+
152
+ Lifecycle hooks can be passed as options (`onOpen`, `onMessage`, `onStatus`, `onError`,
153
+ `onClose`) or subscribed later; both forms are equivalent.
154
+
155
+ #### Properties
156
+
157
+ | Property | Type | Description |
158
+ | --- | --- | --- |
159
+ | `status` | `SSEStatus` | `idle` · `connecting` · `open` · `reconnecting` · `closed` |
160
+ | `closed` | `boolean` | Terminal state; the client will not reconnect |
161
+ | `attempts` | `number` | Consecutive retries; resets after a stable connection |
162
+ | `lastEventId` | `string` | Latest event id, sent back on reconnect |
163
+ | `done` | `Promise<void>` | Resolves when the connection terminates; never rejects |
103
164
 
104
- 消息结构:`{ id, event, data, raw, receivedAt }`,`receivedAt` 便于做本地过期判断。
165
+ #### Methods
105
166
 
106
- 状态流转:`idle → connecting → open → reconnecting → closed`,`connecting/open/closed` 可直接绑 UI;断网或切后台挂起时回到 `idle`。
167
+ | Method | Description |
168
+ | --- | --- |
169
+ | `start()` | Open the connection (no-op while running or suspended) |
170
+ | `close()` | Close and stop reconnecting |
171
+ | `restart()` | Close, then reconnect while keeping `lastEventId` |
172
+ | `on(event, handler)` | Subscribe to one event name; returns an unsubscribe function |
173
+ | `on("*", handler)` | Subscribe to every message |
174
+ | `once(event, handler)` | Subscribe for a single delivery |
175
+ | `onMessage` / `onStatus` / `onError` / `onOpen` / `onClose` | Lifecycle subscriptions |
107
176
 
108
- ### 断网与前后台(默认行为)
177
+ ### `SSEMessage`
109
178
 
110
- - **断网**(`offline` 事件):立即中断当前连接并停止重连,不再打服务端;`online` 后立刻重连(带 `Last-Event-ID`)。避免离线期间的重试风暴。
111
- - **切后台**:默认不影响连接;`pauseWhenHidden: true` 时页面隐藏即挂起、回到前台重连。WebView 后台会被系统挂起,留着连接只会变僵尸,移动端建议开启。
112
- - 主动 `close()` 会解绑这些全局监听,不留残留。
179
+ ```ts
180
+ interface SSEMessage<T = unknown> {
181
+ id: string; // event id, inherited from the previous event when omitted
182
+ event: string; // "message" when the server sends no `event:` field
183
+ data: T; // `parse` output
184
+ raw: string; // raw payload text
185
+ receivedAt: number; // client receive timestamp
186
+ }
187
+ ```
113
188
 
114
- ### 错误分类
189
+ ### Error kinds
115
190
 
116
- `SSEError.kind` 用于日志聚合和策略分支:
191
+ `SSEError.kind` is meant for logging and for branching inside a retry policy:
117
192
 
118
- | kind | 含义 | 默认是否重连 |
193
+ | `kind` | Meaning | Retried by default |
119
194
  | --- | --- | --- |
120
- | `http` | 非 2xx(`status` 有值) | 408/425/429/5xx 重连,其余终止 |
121
- | `network` | DNS / 连接重置等 | 是 |
122
- | `heartbeat` | 静默超时,连接疑似僵死 | 是 |
123
- | `protocol` | content-type 不符、缓冲溢出、body 不可读 | 否 |
124
- | `aborted` | 被中断(切后台、断网、主动关闭) | 否 |
125
- | `retry-exhausted` | 重试次数耗尽 | 否 |
195
+ | `http` | Non-2xx response (`status` is set) | Yes for 408/425/429/5xx, otherwise no |
196
+ | `network` | DNS failure, connection reset, … | Yes |
197
+ | `heartbeat` | Idle timeout, the connection looks zombie | Yes |
198
+ | `protocol` | Wrong `content-type`, buffer overflow, unreadable body | No |
199
+ | `aborted` | Interrupted (hidden page, offline, manual close) | No |
200
+ | `retry-exhausted` | Retry budget spent | No |
126
201
 
127
- ### 重试策略(扩展点)
202
+ ## Recipes
128
203
 
129
- 内置策略对 401/403/404 等 4xx 直接终止,只重试 408/425/429/5xx 与网络异常。需要「401 刷新 token 再重试」这类逻辑时:
204
+ ### Refresh the token on 401
205
+
206
+ A retry policy may be async, so the refresh can complete before the next attempt:
130
207
 
131
208
  ```ts
132
209
  let refreshed = false;
210
+
133
211
  const client = new SSEClient({
134
212
  url,
135
- // 支持 async:401 时先真正刷新 token,再决定是否重连
136
213
  retry: async ({ error, attempt }) => {
137
214
  if (error?.status === 401 && !refreshed) {
138
215
  await refreshToken();
139
216
  refreshed = true;
140
- return 0; // 立即重试
217
+ return 0; // retry immediately
141
218
  }
142
219
  return attempt > 5 ? false : Math.min(30_000, 1000 * 2 ** attempt);
143
220
  },
144
221
  });
145
222
  ```
146
223
 
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
- ### 连接共享
224
+ ### Share one connection
163
225
 
164
- 同一份订阅被多个组件消费时,只建一条连接:
226
+ HTTP/1.1 allows six connections per origin, and a push channel holds one open indefinitely.
227
+ Share it instead of opening one per component:
165
228
 
166
229
  ```ts
230
+ import { acquireSSEClient } from "@songfreedom/sse-client";
231
+
167
232
  const { client, release } = acquireSSEClient("alarm", () => new SSEClient({ url, body }));
168
- onScopeDispose(release); // 最后一个引用释放时自动 close
233
+ onScopeDispose(release); // closes when the last consumer releases
169
234
  ```
170
235
 
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 要能显示「暂无数据」而不是永远显示上次的值。
236
+ ### Batch high-frequency updates
188
237
 
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):
238
+ When the same entity is pushed every few hundred milliseconds, coalesce before writing to state:
197
239
 
198
240
  ```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
- }
241
+ import { createBatcher } from "@songfreedom/sse-client";
228
242
 
229
- return { items, retain, release };
243
+ const batcher = createBatcher<string, Point>({
244
+ key: (point) => `${point.objectId}::${point.code}`,
245
+ interval: 300,
246
+ onFlush: (points) => store.applyPoints(points),
230
247
  });
231
248
 
232
- // 组件里
233
- const alarmStore = useAlarmStore();
234
- alarmStore.retain();
235
- onScopeDispose(() => alarmStore.release());
249
+ client.on("IOT", (payload) => payload.data.forEach((p) => batcher.push(p)));
236
250
  ```
237
251
 
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
+ ### Rotation vs reconnect
252
253
 
253
- ## 开发
254
+ A connection can stay alive while the server-side subscription is already gone. Rotation
255
+ reconnects on a schedule (`rotateInterval`) to avoid such zombie connections, keeping
256
+ `lastEventId` so nothing is missed:
254
257
 
255
- ```bash
256
- pnpm --filter @songfreedom/sse-client test
257
- pnpm --filter @songfreedom/sse-client typecheck
258
- pnpm --filter @songfreedom/sse-client lint
258
+ ```ts
259
+ new SSEClient({ url, rotateInterval: 30 * 60_000 });
259
260
  ```
260
261
 
261
- ## 构建与发布
262
+ This relies on the server honouring `Last-Event-ID`. An alternative is overlapping
263
+ connections (open a replacement, then close the old one), which avoids the gap but can
264
+ deliver duplicates when the server pushes per connection.
262
265
 
263
- **双入口设计**,开发时零构建、发布时只发产物:
266
+ ## Low-level building blocks
264
267
 
265
- | 场景 | 走哪里 | 原因 |
266
- | --- | --- | --- |
267
- | 仓库内(`apps/web` 等) | `exports` → `./src/*.ts` | Vite 直接编译源码,改库代码立即 HMR |
268
- | npm 包 | `dist/` + `dist/package.json` | 消费方拿到 JS + `.d.ts`,不需要编译 TS |
268
+ The client is assembled from small pieces that are exported for advanced use:
269
269
 
270
- `pnpm build` 做两件事:`tsc` 把 `src` 编译成 ESM + `.d.ts` + sourcemap 到 `dist/`,然后由 `scripts/build-package.mjs` 生成 `dist/package.json` 并拷入 README。
270
+ ```ts
271
+ import { createSSEParser, createSSEMessageStream, createBatcher } from "@songfreedom/sse-client";
271
272
 
272
- 发布到 npmjs(`publishConfig` 已指向 `registry.npmjs.org` + `access: public`):
273
+ // bytes → messages, as a standard TransformStream
274
+ const messages = response.body!.pipeThrough(createSSEMessageStream({ parse: JSON.parse }));
273
275
 
274
- ```bash
275
- npm login --registry https://registry.npmjs.org # 只需一次
276
- pnpm --filter @songfreedom/sse-client publish:npm # build + publish
276
+ // raw frames, if you want to drive the loop yourself
277
+ const parser = createSSEParser({ onEvent: (event) => console.log(event) });
278
+ parser.feed("event: ping\ndata: 1\n\n");
277
279
  ```
278
280
 
279
- 只想先看打包内容不发布:
281
+ ## Server requirements
280
282
 
281
- ```bash
282
- pnpm --filter @songfreedom/sse-client publish --dry-run --no-git-checks
283
- ```
283
+ - Respond with `Content-Type: text/event-stream` (a `;charset=UTF-8` suffix is fine).
284
+ - Do not buffer the response: `proxy_buffering off; proxy_cache off;` at the gateway,
285
+ and do not gzip the stream.
286
+ - Send a heartbeat comment (`: ping\n\n`) every 15–30s, otherwise most load balancers
287
+ close the connection after 60s of silence. Pair it with `idleTimeout`.
288
+ - Support `Last-Event-ID` if you want gap-free reconnects; the client sends the header but
289
+ does not de-duplicate events.
284
290
 
285
- 约定:
291
+ ## Design notes
286
292
 
287
- - 发布走 `publishConfig.directory: dist`,所以**必须先 build**(`publish:npm` 已包含)。
288
- - `vue` 是 **optional peer**,非 Vue 项目安装不会被迫拉 vue。
289
- - `sideEffects: false`,消费方可 tree-shaking。
290
- - 版本号每次发布必须递增,npm 不允许覆盖已有版本。
293
+ - No business logic: the client does not know about tokens, stores, toasts or routing.
294
+ Authentication is injected through `headers`.
295
+ - Errors never escape as exceptions from callbacks; a throwing handler is isolated and
296
+ reported through `onHandlerError` while the stream keeps flowing.
297
+ - Timers are always cleared on close, and the connection loop only exits when the client is
298
+ finally closed, so suspending and resuming cannot leak a loop.
291
299
 
292
300
  ## License
293
301
 
294
302
  [MIT](./LICENSE)
295
-
package/batch.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * 高频推送的合并窗口。
2
+ * Coalescing window for high-frequency streams.
3
3
  *
4
- * 业务推送常见「同一对象在几百毫秒内连推多次」的场景(IOT 点位、设备状态),
5
- * 逐条写库会引发渲染风暴。这里按业务 key 合并、保留最新值,在一个时间窗后统一回调。
4
+ * A single entity is often pushed several times within a few hundred
5
+ * milliseconds (IoT points, device state). Writing every message to state
6
+ * causes render storms, so messages are merged by a business key while keeping
7
+ * the latest value, and flushed once per window.
6
8
  *
7
9
  * ```ts
8
10
  * const batcher = createBatcher<number, Point>({
@@ -14,23 +16,23 @@
14
16
  * ```
15
17
  */
16
18
  export interface BatcherOptions<K, V> {
17
- /** 合并用的业务键,同一 key 只保留最后一次的值 */
19
+ /** Business key used to merge values; only the last value per key survives. */
18
20
  key: (value: V) => K;
19
- /** 合并窗口(毫秒),默认 300 */
21
+ /** Coalescing window in milliseconds (default 300). */
20
22
  interval?: number;
21
- /** 窗口结束时的回调,按首次出现的顺序给出每个 key 的最新值 */
23
+ /** Called once per window with the latest value per key, in first-seen order. */
22
24
  onFlush: (values: V[]) => void;
23
25
  }
24
26
  export interface Batcher<V> {
25
- /** 写入一条数据;同一 key 覆盖旧值 */
27
+ /** Accept a value; replaces any pending value with the same key. */
26
28
  push: (value: V) => void;
27
- /** 立即冲刷并取消本次窗口 */
29
+ /** Flush immediately and cancel the pending window. */
28
30
  flush: () => void;
29
- /** 丢弃尚未冲刷的数据 */
31
+ /** Drop pending values without flushing. */
30
32
  clear: () => void;
31
- /** 冲刷并释放定时器(组件卸载时调用) */
33
+ /** Flush and release the timer (call on unmount). */
32
34
  dispose: () => void;
33
- /** 待冲刷的数据条数 */
35
+ /** Number of values waiting to be flushed. */
34
36
  readonly pending: number;
35
37
  }
36
38
  export declare function createBatcher<K, V>(options: BatcherOptions<K, V>): Batcher<V>;
package/batch.d.ts.map CHANGED
@@ -1 +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"}
1
+ {"version":3,"file":"batch.d.ts","sourceRoot":"","sources":["../src/batch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,EAAE,CAAC;IAClC,+EAA+E;IAC/E,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC;IACrB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,OAAO,CAAC,CAAC;IACxB,oEAAoE;IACpE,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IACzB,uDAAuD;IACvD,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,qDAAqD;IACrD,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,8CAA8C;IAC9C,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.map CHANGED
@@ -1 +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"}
1
+ {"version":3,"file":"batch.js","sourceRoot":"","sources":["../src/batch.ts"],"names":[],"mappings":"AAuCA,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"}