@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 +203 -196
- package/batch.d.ts +13 -11
- package/batch.d.ts.map +1 -1
- package/batch.js.map +1 -1
- package/client.d.ts +70 -60
- package/client.d.ts.map +1 -1
- package/client.js +56 -45
- package/client.js.map +1 -1
- package/errors.d.ts +16 -13
- package/errors.d.ts.map +1 -1
- package/errors.js +20 -9
- package/errors.js.map +1 -1
- package/package.json +2 -2
- package/parser.d.ts +14 -13
- package/parser.d.ts.map +1 -1
- package/parser.js +14 -11
- package/parser.js.map +1 -1
- package/share.d.ts +10 -7
- package/share.d.ts.map +1 -1
- package/share.js +9 -6
- package/share.js.map +1 -1
- package/stream.d.ts +7 -5
- package/stream.d.ts.map +1 -1
- package/stream.js +12 -7
- package/stream.js.map +1 -1
- package/types.d.ts +10 -10
- package/types.d.ts.map +1 -1
- package/vue.d.ts +12 -12
- package/vue.d.ts.map +1 -1
- package/vue.js +4 -3
- package/vue.js.map +1 -1
package/README.md
CHANGED
|
@@ -1,295 +1,302 @@
|
|
|
1
1
|
# @songfreedom/sse-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@songfreedom/sse-client)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](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
|
-
```
|
|
8
|
-
|
|
9
|
-
```
|
|
11
|
+
```ts
|
|
12
|
+
import { SSEClient } from "@songfreedom/sse-client";
|
|
10
13
|
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
pnpm add @songfreedom/sse-client vue
|
|
21
|
+
client.on("ALARM", (alarm) => console.log(alarm.level));
|
|
15
22
|
```
|
|
16
23
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
70
|
+
interface Events {
|
|
71
|
+
ALARM: { level: "high" | "low"; deviceId: string };
|
|
72
|
+
IOT: { objectId: string; value: string };
|
|
73
|
+
}
|
|
49
74
|
|
|
50
|
-
const client = new SSEClient<
|
|
51
|
-
url:
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
81
|
+
idleTimeout: 45_000, // if the server sends a heartbeat every 15s
|
|
56
82
|
});
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
```
|
|
96
|
+
```vue
|
|
97
|
+
<script setup lang="ts">
|
|
98
|
+
import { computed } from "vue";
|
|
70
99
|
import { useSSE } from "@songfreedom/sse-client/vue";
|
|
71
100
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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`
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
165
|
+
#### Methods
|
|
105
166
|
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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` |
|
|
121
|
-
| `network` | DNS
|
|
122
|
-
| `heartbeat` |
|
|
123
|
-
| `protocol` | content-type
|
|
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
|
-
|
|
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
|
-
|
|
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); //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
256
|
-
|
|
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
|
-
|
|
270
|
+
```ts
|
|
271
|
+
import { createSSEParser, createSSEMessageStream, createBatcher } from "@songfreedom/sse-client";
|
|
271
272
|
|
|
272
|
-
|
|
273
|
+
// bytes → messages, as a standard TransformStream
|
|
274
|
+
const messages = response.body!.pipeThrough(createSSEMessageStream({ parse: JSON.parse }));
|
|
273
275
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
-
|
|
288
|
-
|
|
289
|
-
-
|
|
290
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
19
|
+
/** Business key used to merge values; only the last value per key survives. */
|
|
18
20
|
key: (value: V) => K;
|
|
19
|
-
/**
|
|
21
|
+
/** Coalescing window in milliseconds (default 300). */
|
|
20
22
|
interval?: number;
|
|
21
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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":"
|
|
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"}
|