@songfreedom/sse-client 0.0.0-stage → 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/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,302 @@
1
- # Temporary Holding Version
1
+ # @songfreedom/sse-client
2
2
 
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.
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)
6
+
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.
10
+
11
+ ```ts
12
+ import { SSEClient } from "@songfreedom/sse-client";
13
+
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
+ });
20
+
21
+ client.on("ALARM", (alarm) => console.log(alarm.level));
22
+ ```
23
+
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 |
31
+
32
+ This library is roughly 3 kB gzipped, has no runtime dependencies, and only needs `fetch`
33
+ and `ReadableStream`.
34
+
35
+ ## Features
36
+
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
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pnpm add @songfreedom/sse-client
51
+ # or
52
+ npm install @songfreedom/sse-client
53
+ ```
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
59
+ ```
60
+
61
+ Requires a browser with `fetch` and `ReadableStream` (all evergreen browsers), or Node 18+ for SSR and tests.
62
+
63
+ ## Quick start
64
+
65
+ ### Framework-agnostic
66
+
67
+ ```ts
68
+ import { SSEClient } from "@songfreedom/sse-client";
69
+
70
+ interface Events {
71
+ ALARM: { level: "high" | "low"; deviceId: string };
72
+ IOT: { objectId: string; value: string };
73
+ }
74
+
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
80
+ parse: (raw) => JSON.parse(raw),
81
+ idleTimeout: 45_000, // if the server sends a heartbeat every 15s
82
+ });
83
+
84
+ const off = client.on("ALARM", (alarm) => {
85
+ // `alarm` is typed as Events["ALARM"]
86
+ console.log(alarm.deviceId);
87
+ });
88
+
89
+ // Later
90
+ off();
91
+ client.close();
92
+ ```
93
+
94
+ ### Vue 3
95
+
96
+ ```vue
97
+ <script setup lang="ts">
98
+ import { computed } from "vue";
99
+ import { useSSE } from "@songfreedom/sse-client/vue";
100
+
101
+ interface Events {
102
+ ALARM: { level: "high" | "low"; deviceId: string };
103
+ }
104
+
105
+ const { status, lastEvent, on } = useSSE<Events>({
106
+ url: "/api/push/alarm",
107
+ body: { topics: ["alarm"] },
108
+ parse: (raw) => JSON.parse(raw),
109
+ });
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>
118
+ ```
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
+
124
+ ## API
125
+
126
+ ### `new SSEClient(options)`
127
+
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 |
164
+
165
+ #### Methods
166
+
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 |
176
+
177
+ ### `SSEMessage`
178
+
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
+ ```
188
+
189
+ ### Error kinds
190
+
191
+ `SSEError.kind` is meant for logging and for branching inside a retry policy:
192
+
193
+ | `kind` | Meaning | Retried by default |
194
+ | --- | --- | --- |
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 |
201
+
202
+ ## Recipes
203
+
204
+ ### Refresh the token on 401
205
+
206
+ A retry policy may be async, so the refresh can complete before the next attempt:
207
+
208
+ ```ts
209
+ let refreshed = false;
210
+
211
+ const client = new SSEClient({
212
+ url,
213
+ retry: async ({ error, attempt }) => {
214
+ if (error?.status === 401 && !refreshed) {
215
+ await refreshToken();
216
+ refreshed = true;
217
+ return 0; // retry immediately
218
+ }
219
+ return attempt > 5 ? false : Math.min(30_000, 1000 * 2 ** attempt);
220
+ },
221
+ });
222
+ ```
223
+
224
+ ### Share one connection
225
+
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:
228
+
229
+ ```ts
230
+ import { acquireSSEClient } from "@songfreedom/sse-client";
231
+
232
+ const { client, release } = acquireSSEClient("alarm", () => new SSEClient({ url, body }));
233
+ onScopeDispose(release); // closes when the last consumer releases
234
+ ```
235
+
236
+ ### Batch high-frequency updates
237
+
238
+ When the same entity is pushed every few hundred milliseconds, coalesce before writing to state:
239
+
240
+ ```ts
241
+ import { createBatcher } from "@songfreedom/sse-client";
242
+
243
+ const batcher = createBatcher<string, Point>({
244
+ key: (point) => `${point.objectId}::${point.code}`,
245
+ interval: 300,
246
+ onFlush: (points) => store.applyPoints(points),
247
+ });
248
+
249
+ client.on("IOT", (payload) => payload.data.forEach((p) => batcher.push(p)));
250
+ ```
251
+
252
+ ### Rotation vs reconnect
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:
257
+
258
+ ```ts
259
+ new SSEClient({ url, rotateInterval: 30 * 60_000 });
260
+ ```
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.
265
+
266
+ ## Low-level building blocks
267
+
268
+ The client is assembled from small pieces that are exported for advanced use:
269
+
270
+ ```ts
271
+ import { createSSEParser, createSSEMessageStream, createBatcher } from "@songfreedom/sse-client";
272
+
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");
279
+ ```
280
+
281
+ ## Server requirements
282
+
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.
290
+
291
+ ## Design notes
292
+
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.
299
+
300
+ ## License
301
+
302
+ [MIT](./LICENSE)
package/batch.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Coalescing window for high-frequency streams.
3
+ *
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.
8
+ *
9
+ * ```ts
10
+ * const batcher = createBatcher<number, Point>({
11
+ * key: (p) => p.pointId,
12
+ * interval: 300,
13
+ * onFlush: (points) => store.applyPoints(points),
14
+ * });
15
+ * client.on("IOT", (p) => batcher.push(p));
16
+ * ```
17
+ */
18
+ export interface BatcherOptions<K, V> {
19
+ /** Business key used to merge values; only the last value per key survives. */
20
+ key: (value: V) => K;
21
+ /** Coalescing window in milliseconds (default 300). */
22
+ interval?: number;
23
+ /** Called once per window with the latest value per key, in first-seen order. */
24
+ onFlush: (values: V[]) => void;
25
+ }
26
+ export interface Batcher<V> {
27
+ /** Accept a value; replaces any pending value with the same key. */
28
+ push: (value: V) => void;
29
+ /** Flush immediately and cancel the pending window. */
30
+ flush: () => void;
31
+ /** Drop pending values without flushing. */
32
+ clear: () => void;
33
+ /** Flush and release the timer (call on unmount). */
34
+ dispose: () => void;
35
+ /** Number of values waiting to be flushed. */
36
+ readonly pending: number;
37
+ }
38
+ export declare function createBatcher<K, V>(options: BatcherOptions<K, V>): Batcher<V>;
39
+ //# 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;;;;;;;;;;;;;;;;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 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":"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"}
package/client.d.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { SSEError } from "./errors.js";
2
+ import type { SSECloseReason, SSEEventMap, SSEMessage, SSEStatus, Unsubscribe } from "./types.js";
3
+ /** Same shape as `fetch`; inject a custom transport for SSR, tests or signed requests. */
4
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
5
+ /** Context passed to a retry policy. */
6
+ export interface RetryContext {
7
+ /** Retry number, starting at 1. */
8
+ attempt: number;
9
+ /** Why the reconnect happens; `undefined` when the server closed the stream normally. */
10
+ error?: SSEError;
11
+ /** How long the attempt was alive, in milliseconds. */
12
+ elapsed: number;
13
+ }
14
+ /**
15
+ * Custom retry policy: return the delay in milliseconds before the next attempt,
16
+ * or `false` to give up. May be async, which makes flows such as
17
+ * "401 → refresh the token → retry" straightforward. When omitted, the built-in
18
+ * exponential backoff with jitter and a max attempt count is used.
19
+ */
20
+ export type RetryPolicy = (context: RetryContext) => number | false | Promise<number | false>;
21
+ /** Client options. */
22
+ export interface SSEClientOptions {
23
+ /** Request URL (include your gateway prefix yourself). */
24
+ url: string;
25
+ /** HTTP method; business push endpoints usually use POST. */
26
+ method?: "GET" | "POST";
27
+ /** Request body. Serialized as JSON, or passed through when it is a string / Blob / FormData. */
28
+ body?: unknown;
29
+ /**
30
+ * Extra headers. Pass a function to recompute them before every (re)connect,
31
+ * which is the recommended way to pick up a refreshed token:
32
+ * `() => getAuthHeaders()`.
33
+ */
34
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
35
+ /** Payload decoder, e.g. `(raw) => JSON.parse(raw)`. Defaults to the raw string. */
36
+ parse?: (raw: string) => unknown;
37
+ /**
38
+ * Idle timeout in milliseconds: reconnect when no bytes arrived for this long.
39
+ * `0` disables it. Two to three times the server heartbeat interval is a good value.
40
+ */
41
+ idleTimeout?: number;
42
+ /** Proactive rotation interval in milliseconds: reconnect on schedule to avoid zombie connections. `0` disables it. */
43
+ rotateInterval?: number;
44
+ /** Custom retry policy, overrides the built-in backoff. */
45
+ retry?: RetryPolicy;
46
+ /** Maximum retry count, unlimited by default (only used when `retry` is not provided). */
47
+ maxRetry?: number;
48
+ /** Delay before the first retry in milliseconds, defaults to 1000. */
49
+ retryBaseDelay?: number;
50
+ /** Upper bound for reconnect delays in milliseconds, defaults to 30000. */
51
+ retryMaxDelay?: number;
52
+ /** Reconnect when the server closes the stream normally, defaults to true. */
53
+ reconnectOnEnd?: boolean;
54
+ /** An attempt that lived at least this long resets the retry counter, defaults to 10000. */
55
+ stableAfter?: number;
56
+ /**
57
+ * Require the response `content-type` to be `text/event-stream`, defaults to true.
58
+ * This catches silent failures such as a gateway returning a 200 HTML page;
59
+ * disable it for non-compliant backends.
60
+ */
61
+ strictContentType?: boolean;
62
+ /** Cap for a single message buffer in characters, defaults to 1MB, guards against runaway streams. */
63
+ maxBufferSize?: number;
64
+ /**
65
+ * Stream-end sentinel: when `data` equals this value the stream is treated as
66
+ * finished (OpenAI / AI SDK style `[DONE]`) and no reconnect happens.
67
+ * Disabled by default.
68
+ */
69
+ endSignal?: string;
70
+ /** Event allow-list: only these event names are dispatched. All events are dispatched when omitted. */
71
+ events?: string[];
72
+ /**
73
+ * Suspend the connection while the page is hidden and reconnect when it becomes
74
+ * visible again. Defaults to false; recommended for WebViews and mobile, where
75
+ * the OS freezes background pages and leaves zombie connections behind.
76
+ */
77
+ pauseWhenHidden?: boolean;
78
+ /** Connect in the constructor, defaults to true. */
79
+ autoStart?: boolean;
80
+ /** Inject a fetch implementation, defaults to the global `fetch`. */
81
+ fetch?: FetchLike;
82
+ /** Fallback when a handler throws, defaults to `console.error`. */
83
+ onHandlerError?: (error: unknown) => void;
84
+ /** Fired once the connection is established (response headers received). Same as `client.onOpen()`. */
85
+ onOpen?: (response: Response) => void;
86
+ /** Fired for every message. Same as `client.onMessage()`. */
87
+ onMessage?: (message: SSEMessage) => void;
88
+ /** Fired on every state change. Same as `client.onStatus()`. */
89
+ onStatus?: (status: SSEStatus) => void;
90
+ /** Fired for every failed attempt. Same as `client.onError()`. */
91
+ onError?: (error: SSEError, attempt: number) => void;
92
+ /** Fired once when the connection is finally closed. Same as `client.onClose()`. */
93
+ onClose?: (reason: SSECloseReason, error?: SSEError) => void;
94
+ }
95
+ /**
96
+ * Server-Sent Events client.
97
+ *
98
+ * - Built on `fetch` + `ReadableStream`, so custom headers and POST bodies work
99
+ * - Reconnects automatically (exponential backoff with jitter); terminal errors
100
+ * such as 401 stop immediately
101
+ * - Resumes with `Last-Event-ID`
102
+ * - Routes events by name to multiple subscribers, so one connection serves many components
103
+ * - Idle watchdog, proactive rotation, offline/background suspension, shared
104
+ * connections (see `acquireSSEClient`)
105
+ */
106
+ export declare class SSEClient<E extends object = SSEEventMap> {
107
+ private readonly options;
108
+ private readonly parser;
109
+ private readonly eventFilter;
110
+ private state;
111
+ private finished;
112
+ private running;
113
+ private suspended;
114
+ private attempt;
115
+ private lastEventIdValue;
116
+ private serverRetryDelay;
117
+ private idleTimedOut;
118
+ private rotating;
119
+ private idleTimer;
120
+ private rotateTimer;
121
+ private cancelController;
122
+ private attemptController;
123
+ private runPromise;
124
+ private resumeWaiter;
125
+ private lifecycleBound;
126
+ private onVisibilityChange?;
127
+ private onOffline?;
128
+ private onOnline?;
129
+ private readonly messageHandlers;
130
+ private readonly eventHandlers;
131
+ private readonly statusHandlers;
132
+ private readonly errorHandlers;
133
+ private readonly openHandlers;
134
+ private readonly closeHandlers;
135
+ constructor(options: SSEClientOptions);
136
+ /** Current state. */
137
+ get status(): SSEStatus;
138
+ /** Whether the client is finally closed and will not reconnect. */
139
+ get closed(): boolean;
140
+ /** Consecutive retry count; resets once a connection proves stable. */
141
+ get attempts(): number;
142
+ /** Latest event id, sent back as `Last-Event-ID` on reconnect. */
143
+ get lastEventId(): string;
144
+ /** Resolves when the connection terminates (closed or retries exhausted). Never rejects. */
145
+ get done(): Promise<void>;
146
+ /** Open the connection. No-op while already running or suspended. */
147
+ start(): this;
148
+ /** Close the connection and stop reconnecting. */
149
+ close(): void;
150
+ /** Reconnect after closing, keeping `lastEventId` so the server can resume. */
151
+ restart(): this;
152
+ /** Subscribe to one event name; returns an unsubscribe function. */
153
+ on<K extends Extract<keyof E, string>>(event: K, handler: (data: E[K], message: SSEMessage) => void): Unsubscribe;
154
+ /** Subscribe to every message by passing `"*"`. */
155
+ on(event: "*", handler: (data: unknown, message: SSEMessage) => void): Unsubscribe;
156
+ /** Subscribe to one event name for a single delivery. */
157
+ once<K extends Extract<keyof E, string>>(event: K, handler: (data: E[K], message: SSEMessage) => void): Unsubscribe;
158
+ /** Subscribe to every message regardless of event name. */
159
+ onMessage(handler: (message: SSEMessage) => void): Unsubscribe;
160
+ onStatus(handler: (status: SSEStatus) => void): Unsubscribe;
161
+ onError(handler: (error: SSEError, attempt: number) => void): Unsubscribe;
162
+ onOpen(handler: (response: Response) => void): Unsubscribe;
163
+ onClose(handler: (reason: SSECloseReason, error?: SSEError) => void): Unsubscribe;
164
+ private get retryBaseDelay();
165
+ private get retryMaxDelay();
166
+ private get maxRetry();
167
+ private get stableAfter();
168
+ private get reconnectOnEnd();
169
+ private reset;
170
+ private connect;
171
+ /** Open one connection and read it until the stream ends (throws on failure). */
172
+ private openOnce;
173
+ private resolveHeaders;
174
+ private handleParsedEvent;
175
+ private dispatch;
176
+ private emit;
177
+ private safeCall;
178
+ private reportHandlerError;
179
+ private decideDelay;
180
+ /** Reconnect delay: the server's `retry:` wins, otherwise exponential backoff with ±20% jitter. */
181
+ private nextDelay;
182
+ private sleep;
183
+ private waitForResume;
184
+ private armIdle;
185
+ private armRotate;
186
+ private clearIdle;
187
+ private clearRotate;
188
+ private clearTimers;
189
+ private setState;
190
+ /** Network state is always observed: no reconnects while offline, an immediate one when back online. */
191
+ private bindLifecycle;
192
+ private unbindLifecycle;
193
+ private suspend;
194
+ private resume;
195
+ private finish;
196
+ }
197
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}