@songfreedom/sse-client 0.1.0 → 0.1.2
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 +213 -190
- 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 +3 -4
- 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,318 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
## Why not `EventSource`?
|
|
25
|
+
|
|
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 |
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
This library is roughly 3 kB gzipped, has no runtime dependencies, and only needs `fetch`
|
|
33
|
+
and `ReadableStream`.
|
|
23
34
|
|
|
24
|
-
##
|
|
35
|
+
## Features
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
31
46
|
|
|
32
|
-
##
|
|
47
|
+
## Installation
|
|
33
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.
|
|
42
62
|
|
|
43
|
-
##
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
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
|
-
|
|
224
|
+
### Share one connection
|
|
148
225
|
|
|
149
|
-
|
|
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
|
-
同一份订阅被多个组件消费时,只建一条连接:
|
|
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 要能显示「暂无数据」而不是永远显示上次的值。
|
|
188
|
-
|
|
189
|
-
**不要照搬的四点**
|
|
236
|
+
### Batch high-frequency updates
|
|
190
237
|
|
|
191
|
-
|
|
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
|
-
|
|
252
|
+
### Rotation vs reconnect
|
|
239
253
|
|
|
240
|
-
|
|
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:
|
|
241
257
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
- 事件类型通过 `event:` 字段下发,客户端按该字段路由。
|
|
258
|
+
```ts
|
|
259
|
+
new SSEClient({ url, rotateInterval: 30 * 60_000 });
|
|
260
|
+
```
|
|
246
261
|
|
|
247
|
-
|
|
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.
|
|
248
265
|
|
|
249
|
-
|
|
266
|
+
## Low-level building blocks
|
|
250
267
|
|
|
251
|
-
|
|
268
|
+
The client is assembled from small pieces that are exported for advanced use:
|
|
252
269
|
|
|
253
|
-
|
|
270
|
+
```ts
|
|
271
|
+
import { createSSEParser, createSSEMessageStream, createBatcher } from "@songfreedom/sse-client";
|
|
254
272
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
273
|
+
// bytes → messages, as a standard TransformStream
|
|
274
|
+
const messages = response.body!.pipeThrough(createSSEMessageStream({ parse: JSON.parse }));
|
|
275
|
+
|
|
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");
|
|
259
279
|
```
|
|
260
280
|
|
|
261
|
-
##
|
|
281
|
+
## Server requirements
|
|
262
282
|
|
|
263
|
-
|
|
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.
|
|
264
290
|
|
|
265
|
-
|
|
266
|
-
| --- | --- | --- |
|
|
267
|
-
| 仓库内(`apps/web` 等) | `exports` → `./src/*.ts` | Vite 直接编译源码,改库代码立即 HMR |
|
|
268
|
-
| npm 包 | `dist/` + `dist/package.json` | 消费方拿到 JS + `.d.ts`,不需要编译 TS |
|
|
291
|
+
## Design notes
|
|
269
292
|
|
|
270
|
-
|
|
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.
|
|
271
299
|
|
|
272
|
-
|
|
300
|
+
## Development
|
|
273
301
|
|
|
274
302
|
```bash
|
|
275
|
-
|
|
276
|
-
pnpm
|
|
303
|
+
pnpm install
|
|
304
|
+
pnpm test # vitest
|
|
305
|
+
pnpm typecheck # tsc --noEmit
|
|
306
|
+
pnpm lint # eslint
|
|
307
|
+
pnpm build # dist/ + dist/package.json
|
|
277
308
|
```
|
|
278
309
|
|
|
279
|
-
|
|
310
|
+
Releasing: bump the version, then
|
|
280
311
|
|
|
281
312
|
```bash
|
|
282
|
-
pnpm
|
|
313
|
+
pnpm publish:npm # build + publish to npmjs (access: public)
|
|
283
314
|
```
|
|
284
315
|
|
|
285
|
-
约定:
|
|
286
|
-
|
|
287
|
-
- 发布走 `publishConfig.directory: dist`,所以**必须先 build**(`publish:npm` 已包含)。
|
|
288
|
-
- `vue` 是 **optional peer**,非 Vue 项目安装不会被迫拉 vue。
|
|
289
|
-
- `sideEffects: false`,消费方可 tree-shaking。
|
|
290
|
-
- 版本号每次发布必须递增,npm 不允许覆盖已有版本。
|
|
291
|
-
|
|
292
316
|
## License
|
|
293
317
|
|
|
294
318
|
[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"}
|