@0k/kal-sse-client 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +261 -2
- package/README.org +159 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +129 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valentin Lab
|
|
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,262 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @0k/kal-sse-client
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@0k/kal-sse-client)
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
A kal-sse data channel pairs a REST snapshot with an SSE stream. The
|
|
8
|
+
snapshot registers or refreshes the subscription, the server owns its
|
|
9
|
+
`sub` id, and each stream event carries an SSE `id:`. On reconnect, the
|
|
10
|
+
client can send the last applied event id so the server can replay from
|
|
11
|
+
that point.
|
|
12
|
+
|
|
13
|
+
`@0k/kal-sse-client` owns the `EventSource` lifecycle, reconnect
|
|
14
|
+
backoff, snapshot registration, and resume-first recovery. Your
|
|
15
|
+
application owns endpoint paths, URL resolution, parsing event payloads,
|
|
16
|
+
and rendering or otherwise updating application state. It supports
|
|
17
|
+
Chrome 91 and later.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
``` bash
|
|
22
|
+
npm install @0k/kal-sse-client
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Snapshot and cursor channel
|
|
28
|
+
|
|
29
|
+
Use `createSnapshotCursorSse` for the usual kal-sse data channel: fetch
|
|
30
|
+
a snapshot, render it, then apply changes from its SSE stream.
|
|
31
|
+
|
|
32
|
+
``` ts
|
|
33
|
+
import { createSnapshotCursorSse } from "@0k/kal-sse-client";
|
|
34
|
+
|
|
35
|
+
type Archive = { items: Array<{ id: string; title: string }> };
|
|
36
|
+
type ArchiveChange = { id: string; title: string };
|
|
37
|
+
|
|
38
|
+
const archive = createSnapshotCursorSse<Archive>({
|
|
39
|
+
snapshotPath: "/api/archive",
|
|
40
|
+
streamPath: "/api/archive/events",
|
|
41
|
+
applySnapshot: (snapshot) => renderArchive(snapshot),
|
|
42
|
+
onMessage: (event) => applyArchiveChange(JSON.parse(event.data) as ArchiveChange),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await archive.start();
|
|
46
|
+
|
|
47
|
+
// Later, for example when the view is disposed:
|
|
48
|
+
archive.stop();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`start()` begins the snapshot request and returns one shared promise.
|
|
52
|
+
That promise resolves when the first snapshot succeeds, or when its
|
|
53
|
+
first attempt fails. Later snapshot retries continue in the background,
|
|
54
|
+
so a resolved promise does not by itself mean a stream is already open.
|
|
55
|
+
|
|
56
|
+
The snapshot response must provide a non-empty `x-sub-id` header. For a
|
|
57
|
+
cross-origin snapshot endpoint, expose it to browser JavaScript with
|
|
58
|
+
`Access-Control-Expose-Headers: x-sub-id`. Once the client knows this
|
|
59
|
+
id, each later snapshot request adds `?sub=<id>` (or `&sub=<id>` when
|
|
60
|
+
the path already has a query string). The stream URL always appends
|
|
61
|
+
`sub=<id>` as a query parameter.
|
|
62
|
+
|
|
63
|
+
### Plain SSE endpoint
|
|
64
|
+
|
|
65
|
+
Use `createResilientSse` when the endpoint is not a snapshot cursor
|
|
66
|
+
channel. By default it calls `onBeforeReconnect` before every reconnect,
|
|
67
|
+
which is a place to refetch application state.
|
|
68
|
+
|
|
69
|
+
``` ts
|
|
70
|
+
import { createResilientSse } from "@0k/kal-sse-client";
|
|
71
|
+
|
|
72
|
+
const notifications = createResilientSse({
|
|
73
|
+
url: () => "/api/notifications/events",
|
|
74
|
+
onMessage: (event) => showNotification(JSON.parse(event.data)),
|
|
75
|
+
onBeforeReconnect: async () => {
|
|
76
|
+
const response = await fetch("/api/notifications");
|
|
77
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
78
|
+
replaceNotifications(await response.json());
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
notifications.start();
|
|
83
|
+
// Later:
|
|
84
|
+
notifications.stop();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Reconnection behavior
|
|
88
|
+
|
|
89
|
+
The first reconnect attempt opens immediately. After that, reconnect
|
|
90
|
+
delay starts at 250 ms, doubles after each attempt, and is capped at 30
|
|
91
|
+
s. A delivered event starts a 5 s healthy timer; when that timer
|
|
92
|
+
elapses, the retry delay resets. Keep-alive comments do not count as
|
|
93
|
+
delivered events and never reset the delay.
|
|
94
|
+
|
|
95
|
+
`createSnapshotCursorSse` always resumes first, using its `sub` id and
|
|
96
|
+
the last applied `last_event_id`. `createResilientSse` does this only
|
|
97
|
+
when `resumeFromLastEventId: true`. An event id is remembered only after
|
|
98
|
+
the relevant message callback returns normally.
|
|
99
|
+
|
|
100
|
+
The client fetches a fresh snapshot if no event was applied since the
|
|
101
|
+
last snapshot, or if a resume attempt fails before the stream opens.
|
|
102
|
+
This is deliberately conservative: `EventSource` does not expose the
|
|
103
|
+
HTTP response status, so `409 needs_snapshot` cannot be distinguished
|
|
104
|
+
from other HTTP errors such as a proxy's `502`. A network drop differs:
|
|
105
|
+
the browser retries with `readyState` set to `CONNECTING`. The client
|
|
106
|
+
nevertheless treats any resume attempt that fails before opening the
|
|
107
|
+
same way. While snapshot recovery is pending, the last successfully
|
|
108
|
+
applied snapshot stays rendered.
|
|
109
|
+
|
|
110
|
+
This resume protocol needs a `kal-sse` server version 0.2 or later,
|
|
111
|
+
which accepts `last_event_id`. With a 0.1 server, typical Axum handlers
|
|
112
|
+
ignore that unknown query parameter, so the connection silently falls
|
|
113
|
+
back to the server cursor instead of client-directed replay.
|
|
114
|
+
|
|
115
|
+
## Option reference
|
|
116
|
+
|
|
117
|
+
### `createResilientSse(options)`
|
|
118
|
+
|
|
119
|
+
| Option | Type | Default | Meaning |
|
|
120
|
+
|-------------------------|-------------------------------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------|
|
|
121
|
+
| `url` | `() => string` | Required | Returns the SSE URL for each new connection. |
|
|
122
|
+
| `onMessage` | `(event: MessageEvent) => void` | Required | Handles unnamed SSE messages. A non-empty event id is remembered only after this callback returns when resume is enabled. |
|
|
123
|
+
| `onEvents` | `Record<string, (event: MessageEvent) => void>` | None | Handles named SSE events. Use custom event names only, never the reserved names `open`, `error`, or `message`. |
|
|
124
|
+
| `onBeforeReconnect` | `() => void \| Promise<void>` | None | Runs before a non-resume reconnect. Use it to refetch state for a plain SSE endpoint. A rejected promise is retried with the reconnect delay. |
|
|
125
|
+
| `resumeFromLastEventId` | `boolean` | `false` | When true, reconnect after an applied id and an opened stream with `last_event_id=<id>` appended as a query parameter. |
|
|
126
|
+
| `onError` | `() => void` | None | Runs when the current `EventSource` reports an error, before reconnect scheduling. Exceptions from this notification are swallowed. |
|
|
127
|
+
| `initialDelayMs` | `number` | `250` | First delayed reconnect interval in milliseconds. The first reconnect attempt itself is immediate. |
|
|
128
|
+
| `maxDelayMs` | `number` | `30000` | Maximum reconnect interval in milliseconds. |
|
|
129
|
+
| `healthyWindowMs` | `number` | `5000` | Milliseconds after a delivered event before the reconnect delay resets. |
|
|
130
|
+
|
|
131
|
+
### `createSnapshotCursorSse<S>(options)`
|
|
132
|
+
|
|
133
|
+
| Option | Type | Default | Meaning |
|
|
134
|
+
|--------------------------|---------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
135
|
+
| `snapshotPath` | `string` | Required | Snapshot endpoint path. After receiving a subscription id, later requests add `sub`. |
|
|
136
|
+
| `streamPath` | `string` | Required | SSE endpoint path. The client adds the server-owned `sub` id. |
|
|
137
|
+
| `resolveUrl` | `(path: string) => string` | Identity | Resolves endpoint paths, for example below a reverse-proxy prefix. It is used for both snapshot fetches and stream URLs. |
|
|
138
|
+
| `applySnapshot` | `(body: S) => void` | Required | Applies a successful snapshot after its non-empty `x-sub-id` header is read. |
|
|
139
|
+
| `onMessage` | `(event: MessageEvent) => void` | Required | Handles stream messages. Parse the event payload and update state here. |
|
|
140
|
+
| `retrySnapshotAfter` | `(body: S) => number \| null` | None | Returns a non-negative delay in milliseconds to retry this otherwise successful snapshot without applying it. Return `null` or no value to apply it. |
|
|
141
|
+
| `onSnapshotError` | `(reason: unknown) => void` | None | Receives failed snapshot fetch, HTTP, header, JSON, callback, or retry-delay validation errors before snapshot retry. Exceptions from this notification are swallowed. |
|
|
142
|
+
| `onStreamError` | `() => void` | None | Receives errors reported by the underlying stream before its reconnect logic runs. Exceptions from this notification are swallowed. |
|
|
143
|
+
| `initialSnapshotRetryMs` | `number` | `250` | First retry delay for a failed snapshot in milliseconds. |
|
|
144
|
+
| `maxSnapshotRetryMs` | `number` | `30000` | Maximum snapshot retry delay in milliseconds. |
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
Run the checks separately when working on the package:
|
|
149
|
+
|
|
150
|
+
``` bash
|
|
151
|
+
npm test
|
|
152
|
+
npm run typecheck
|
|
153
|
+
npm run build
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For a local consumer, run `npm link` in this repository, then run
|
|
157
|
+
`npm link @0k/kal-sse-client` in the consumer repository. The release
|
|
158
|
+
process generates `README.md` from this file; do not commit that
|
|
159
|
+
generated file.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT, see `LICENSE`.
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
## Changelog
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
### 0.2.0 (2026-10-10)
|
|
170
|
+
|
|
171
|
+
#### New
|
|
172
|
+
|
|
173
|
+
* Publish on npm instead of a vendored tarball. [Valentin Lab]
|
|
174
|
+
|
|
175
|
+
Consumers depended on a tarball committed into each of their
|
|
176
|
+
repositories and refreshed by ``scripts/vendor.mjs``. The package is now
|
|
177
|
+
released to npm as ``@0k/kal-sse-client`` with ``pkg`` (version from the
|
|
178
|
+
git tag, ``%%version%%`` in ``package.json``); consumers depend on the
|
|
179
|
+
published version and use ``npm link`` against this checkout locally.
|
|
180
|
+
``README.md`` is generated from ``README.org`` at release, with an npm
|
|
181
|
+
badge and an install section.
|
|
182
|
+
|
|
183
|
+
Contract:
|
|
184
|
+
- ``@0k/kal-sse-client`` is a public npm package (no ``private`` flag,
|
|
185
|
+
``publishConfig.access`` is ``public``); its version is the release
|
|
186
|
+
tag, substituted for ``%%version%%`` in ``package.json`` and
|
|
187
|
+
``package-lock.json``.
|
|
188
|
+
- The package ships ``dist``, ``README.md`` and ``LICENSE``; ``prepack``
|
|
189
|
+
builds ``dist`` first. Entry points and exports are unchanged.
|
|
190
|
+
- ``npm run vendor`` and ``scripts/vendor.mjs`` are removed: nothing is
|
|
191
|
+
packed into or installed from a consumer's repository any more.
|
|
192
|
+
- No runtime change and no automated test: packaging only.
|
|
193
|
+
|
|
194
|
+
* Resume a dropped stream from the last applied event instead of re-snapshotting. [Valentin Lab]
|
|
195
|
+
|
|
196
|
+
The server cursor of a ``kal-sse`` channel advances when the server hands
|
|
197
|
+
an event to its HTTP stack, so after a silent disconnect it can be ahead
|
|
198
|
+
of what the client applied: resuming from it lost events. Re-taking the
|
|
199
|
+
snapshot on every reconnect avoided that, at the cost of a full snapshot
|
|
200
|
+
per network blip.
|
|
201
|
+
|
|
202
|
+
``createSnapshotCursorSse`` now remembers the ``id`` of the last applied
|
|
203
|
+
event and reconnects with ``last_event_id``, which ``kal-sse`` 0.2 replays
|
|
204
|
+
from. It re-takes the snapshot only when no event was applied since the
|
|
205
|
+
last one, or when the resume attempt fails before opening (an
|
|
206
|
+
``EventSource`` hides the HTTP status, so a ``409`` looks like that).
|
|
207
|
+
|
|
208
|
+
``createResilientSse`` gains the opt-in ``resumeFromLastEventId`` option
|
|
209
|
+
behind this; without it nothing changes.
|
|
210
|
+
|
|
211
|
+
Contract:
|
|
212
|
+
- ``createSnapshotCursorSse`` always resumes first; ``createResilientSse``
|
|
213
|
+
resumes only with ``resumeFromLastEventId: true`` and otherwise still
|
|
214
|
+
runs ``onBeforeReconnect`` before every reconnect.
|
|
215
|
+
- An event id is remembered only once its ``onMessage`` or ``onEvents``
|
|
216
|
+
callback has returned normally; an empty id never replaces it.
|
|
217
|
+
- After a stream that opened, with a remembered id, the reconnect URL
|
|
218
|
+
gets ``last_event_id=<id>`` (URL-encoded) and ``onBeforeReconnect`` is
|
|
219
|
+
skipped: a snapshot channel fetches no snapshot.
|
|
220
|
+
- With no remembered id, or when a resume fails before opening, the id is
|
|
221
|
+
forgotten and ``onBeforeReconnect`` runs: a snapshot channel re-takes
|
|
222
|
+
its snapshot, adopts the new ``x-sub-id``, and keeps showing the last
|
|
223
|
+
applied snapshot until then.
|
|
224
|
+
- Resume keeps the bounded backoff, the healthy-window reset, and the
|
|
225
|
+
cancellation on ``stop()``.
|
|
226
|
+
- Tests: ``test/resilient.test.ts``, ``test/snapshot-cursor.test.ts``.
|
|
227
|
+
|
|
228
|
+
* Live-update client that reconnects and resumes without gaps. [Valentin Lab]
|
|
229
|
+
|
|
230
|
+
Wraps the ``kal-sse`` snapshot and server-owned cursor contract: bounded
|
|
231
|
+
reconnect backoff, and a fresh snapshot before every reconnect so no
|
|
232
|
+
update is lost while the last applied snapshot stays visible.
|
|
233
|
+
|
|
234
|
+
#### Changes
|
|
235
|
+
|
|
236
|
+
* Document usage, reconnection and every option in the README. [Valentin Lab]
|
|
237
|
+
|
|
238
|
+
The README described the package's internal acceptance ledger instead of
|
|
239
|
+
how to use it. It now gives usage examples for both channel kinds, the
|
|
240
|
+
exact reconnection and resume behavior, a reference table of every
|
|
241
|
+
option (including the ``x-sub-id`` header a snapshot must expose), the
|
|
242
|
+
server requirement and browser support. The acceptance ledger is gone.
|
|
243
|
+
|
|
244
|
+
#### Fix
|
|
245
|
+
|
|
246
|
+
* Keep reconnecting when a notification callback throws, and wait once per retry. [Valentin Lab]
|
|
247
|
+
|
|
248
|
+
- A host ``onError`` or ``onStreamError`` that threw skipped the
|
|
249
|
+
reconnect, and a throwing ``onSnapshotError`` left ``start()`` unresolved
|
|
250
|
+
and stopped the snapshot retries: recovery ended for good.
|
|
251
|
+
- When ``onBeforeReconnect`` rejected, the retry timer waited the backoff
|
|
252
|
+
delay and ``reconnect`` then waited it again, so retries were spaced
|
|
253
|
+
twice the documented delay (up to 60 s with defaults).
|
|
254
|
+
|
|
255
|
+
Contract:
|
|
256
|
+
- Exceptions thrown by ``onError``, ``onStreamError`` and ``onSnapshotError``
|
|
257
|
+
are swallowed; reconnection and snapshot retries continue.
|
|
258
|
+
- After a rejected ``onBeforeReconnect``, the next attempt starts after one
|
|
259
|
+
backoff delay; the delay doubles up to ``maxDelayMs``; ``stop()`` cancels.
|
|
260
|
+
- Tests: ``test/resilient.test.ts``, ``test/snapshot-cursor.test.ts``.
|
|
261
|
+
|
|
2
262
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
package/README.org
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#+TITLE: @0k/kal-sse-client
|
|
2
|
+
|
|
3
|
+
[[https://www.npmjs.com/package/@0k/kal-sse-client][https://img.shields.io/npm/v/@0k/kal-sse-client.svg]]
|
|
4
|
+
|
|
5
|
+
* Overview
|
|
6
|
+
|
|
7
|
+
A kal-sse data channel pairs a REST snapshot with an SSE stream. The snapshot
|
|
8
|
+
registers or refreshes the subscription, the server owns its ~sub~ id, and each
|
|
9
|
+
stream event carries an SSE ~id:~. On reconnect, the client can send the last
|
|
10
|
+
applied event id so the server can replay from that point.
|
|
11
|
+
|
|
12
|
+
=@0k/kal-sse-client= owns the =EventSource= lifecycle, reconnect backoff,
|
|
13
|
+
snapshot registration, and resume-first recovery. Your application owns endpoint
|
|
14
|
+
paths, URL resolution, parsing event payloads, and rendering or otherwise
|
|
15
|
+
updating application state. It supports Chrome 91 and later.
|
|
16
|
+
|
|
17
|
+
* Installation
|
|
18
|
+
|
|
19
|
+
#+begin_src sh
|
|
20
|
+
npm install @0k/kal-sse-client
|
|
21
|
+
#+end_src
|
|
22
|
+
|
|
23
|
+
* Usage
|
|
24
|
+
|
|
25
|
+
** Snapshot and cursor channel
|
|
26
|
+
|
|
27
|
+
Use =createSnapshotCursorSse= for the usual kal-sse data channel: fetch a
|
|
28
|
+
snapshot, render it, then apply changes from its SSE stream.
|
|
29
|
+
|
|
30
|
+
#+begin_src ts
|
|
31
|
+
import { createSnapshotCursorSse } from "@0k/kal-sse-client";
|
|
32
|
+
|
|
33
|
+
type Archive = { items: Array<{ id: string; title: string }> };
|
|
34
|
+
type ArchiveChange = { id: string; title: string };
|
|
35
|
+
|
|
36
|
+
const archive = createSnapshotCursorSse<Archive>({
|
|
37
|
+
snapshotPath: "/api/archive",
|
|
38
|
+
streamPath: "/api/archive/events",
|
|
39
|
+
applySnapshot: (snapshot) => renderArchive(snapshot),
|
|
40
|
+
onMessage: (event) => applyArchiveChange(JSON.parse(event.data) as ArchiveChange),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await archive.start();
|
|
44
|
+
|
|
45
|
+
// Later, for example when the view is disposed:
|
|
46
|
+
archive.stop();
|
|
47
|
+
#+end_src
|
|
48
|
+
|
|
49
|
+
=start()= begins the snapshot request and returns one shared promise. That
|
|
50
|
+
promise resolves when the first snapshot succeeds, or when its first attempt
|
|
51
|
+
fails. Later snapshot retries continue in the background, so a resolved promise
|
|
52
|
+
does not by itself mean a stream is already open.
|
|
53
|
+
|
|
54
|
+
The snapshot response must provide a non-empty =x-sub-id= header. For a
|
|
55
|
+
cross-origin snapshot endpoint, expose it to browser JavaScript with
|
|
56
|
+
=Access-Control-Expose-Headers: x-sub-id=. Once the client knows this id, each
|
|
57
|
+
later snapshot request adds ~?sub=<id>~ (or ~&sub=<id>~ when the path already
|
|
58
|
+
has a query string). The stream URL always appends ~sub=<id>~ as a query
|
|
59
|
+
parameter.
|
|
60
|
+
|
|
61
|
+
** Plain SSE endpoint
|
|
62
|
+
|
|
63
|
+
Use =createResilientSse= when the endpoint is not a snapshot cursor channel.
|
|
64
|
+
By default it calls =onBeforeReconnect= before every reconnect, which is a
|
|
65
|
+
place to refetch application state.
|
|
66
|
+
|
|
67
|
+
#+begin_src ts
|
|
68
|
+
import { createResilientSse } from "@0k/kal-sse-client";
|
|
69
|
+
|
|
70
|
+
const notifications = createResilientSse({
|
|
71
|
+
url: () => "/api/notifications/events",
|
|
72
|
+
onMessage: (event) => showNotification(JSON.parse(event.data)),
|
|
73
|
+
onBeforeReconnect: async () => {
|
|
74
|
+
const response = await fetch("/api/notifications");
|
|
75
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
76
|
+
replaceNotifications(await response.json());
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
notifications.start();
|
|
81
|
+
// Later:
|
|
82
|
+
notifications.stop();
|
|
83
|
+
#+end_src
|
|
84
|
+
|
|
85
|
+
* Reconnection behavior
|
|
86
|
+
|
|
87
|
+
The first reconnect attempt opens immediately. After that, reconnect delay
|
|
88
|
+
starts at 250 ms, doubles after each attempt, and is capped at 30 s. A delivered
|
|
89
|
+
event starts a 5 s healthy timer; when that timer elapses, the retry delay
|
|
90
|
+
resets. Keep-alive comments do not count as delivered events and never reset
|
|
91
|
+
the delay.
|
|
92
|
+
|
|
93
|
+
=createSnapshotCursorSse= always resumes first, using its =sub= id and the
|
|
94
|
+
last applied =last_event_id=. =createResilientSse= does this only when
|
|
95
|
+
=resumeFromLastEventId: true=. An event id is remembered only after the relevant
|
|
96
|
+
message callback returns normally.
|
|
97
|
+
|
|
98
|
+
The client fetches a fresh snapshot if no event was applied since the last
|
|
99
|
+
snapshot, or if a resume attempt fails before the stream opens. This is
|
|
100
|
+
deliberately conservative: =EventSource= does not expose the HTTP response
|
|
101
|
+
status, so =409 needs_snapshot= cannot be distinguished from other HTTP errors
|
|
102
|
+
such as a proxy's =502=. A network drop differs: the browser retries with
|
|
103
|
+
~readyState~ set to ~CONNECTING~. The client nevertheless treats any resume
|
|
104
|
+
attempt that fails before opening the same way. While snapshot recovery is
|
|
105
|
+
pending, the last successfully applied snapshot stays rendered.
|
|
106
|
+
|
|
107
|
+
This resume protocol needs a =kal-sse= server version 0.2 or later, which
|
|
108
|
+
accepts =last_event_id=. With a 0.1 server, typical Axum handlers ignore that
|
|
109
|
+
unknown query parameter, so the connection silently falls back to the server
|
|
110
|
+
cursor instead of client-directed replay.
|
|
111
|
+
|
|
112
|
+
* Option reference
|
|
113
|
+
|
|
114
|
+
** =createResilientSse(options)=
|
|
115
|
+
|
|
116
|
+
| Option | Type | Default | Meaning |
|
|
117
|
+
|-
|
|
118
|
+
| ~url~ | ~() => string~ | Required | Returns the SSE URL for each new connection. |
|
|
119
|
+
| ~onMessage~ | ~(event: MessageEvent) => void~ | Required | Handles unnamed SSE messages. A non-empty event id is remembered only after this callback returns when resume is enabled. |
|
|
120
|
+
| ~onEvents~ | ~Record<string, (event: MessageEvent) => void>~ | None | Handles named SSE events. Use custom event names only, never the reserved names ~open~, ~error~, or ~message~. |
|
|
121
|
+
| ~onBeforeReconnect~ | ~() => void \| Promise<void>~ | None | Runs before a non-resume reconnect. Use it to refetch state for a plain SSE endpoint. A rejected promise is retried with the reconnect delay. |
|
|
122
|
+
| ~resumeFromLastEventId~ | ~boolean~ | ~false~ | When true, reconnect after an applied id and an opened stream with ~last_event_id=<id>~ appended as a query parameter. |
|
|
123
|
+
| ~onError~ | ~() => void~ | None | Runs when the current =EventSource= reports an error, before reconnect scheduling. Exceptions from this notification are swallowed. |
|
|
124
|
+
| ~initialDelayMs~ | ~number~ | ~250~ | First delayed reconnect interval in milliseconds. The first reconnect attempt itself is immediate. |
|
|
125
|
+
| ~maxDelayMs~ | ~number~ | ~30000~ | Maximum reconnect interval in milliseconds. |
|
|
126
|
+
| ~healthyWindowMs~ | ~number~ | ~5000~ | Milliseconds after a delivered event before the reconnect delay resets. |
|
|
127
|
+
|
|
128
|
+
** =createSnapshotCursorSse<S>(options)=
|
|
129
|
+
|
|
130
|
+
| Option | Type | Default | Meaning |
|
|
131
|
+
|-
|
|
132
|
+
| ~snapshotPath~ | ~string~ | Required | Snapshot endpoint path. After receiving a subscription id, later requests add =sub=. |
|
|
133
|
+
| ~streamPath~ | ~string~ | Required | SSE endpoint path. The client adds the server-owned =sub= id. |
|
|
134
|
+
| ~resolveUrl~ | ~(path: string) => string~ | Identity | Resolves endpoint paths, for example below a reverse-proxy prefix. It is used for both snapshot fetches and stream URLs. |
|
|
135
|
+
| ~applySnapshot~ | ~(body: S) => void~ | Required | Applies a successful snapshot after its non-empty =x-sub-id= header is read. |
|
|
136
|
+
| ~onMessage~ | ~(event: MessageEvent) => void~ | Required | Handles stream messages. Parse the event payload and update state here. |
|
|
137
|
+
| ~retrySnapshotAfter~ | ~(body: S) => number \| null~ | None | Returns a non-negative delay in milliseconds to retry this otherwise successful snapshot without applying it. Return ~null~ or no value to apply it. |
|
|
138
|
+
| ~onSnapshotError~ | ~(reason: unknown) => void~ | None | Receives failed snapshot fetch, HTTP, header, JSON, callback, or retry-delay validation errors before snapshot retry. Exceptions from this notification are swallowed. |
|
|
139
|
+
| ~onStreamError~ | ~() => void~ | None | Receives errors reported by the underlying stream before its reconnect logic runs. Exceptions from this notification are swallowed. |
|
|
140
|
+
| ~initialSnapshotRetryMs~ | ~number~ | ~250~ | First retry delay for a failed snapshot in milliseconds. |
|
|
141
|
+
| ~maxSnapshotRetryMs~ | ~number~ | ~30000~ | Maximum snapshot retry delay in milliseconds. |
|
|
142
|
+
|
|
143
|
+
* Development
|
|
144
|
+
|
|
145
|
+
Run the checks separately when working on the package:
|
|
146
|
+
|
|
147
|
+
#+begin_src sh
|
|
148
|
+
npm test
|
|
149
|
+
npm run typecheck
|
|
150
|
+
npm run build
|
|
151
|
+
#+end_src
|
|
152
|
+
|
|
153
|
+
For a local consumer, run ~npm link~ in this repository, then run
|
|
154
|
+
~npm link @0k/kal-sse-client~ in the consumer repository. The release process
|
|
155
|
+
generates ~README.md~ from this file; do not commit that generated file.
|
|
156
|
+
|
|
157
|
+
* License
|
|
158
|
+
|
|
159
|
+
MIT, see ~LICENSE~.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function M(e){const y=e.initialDelayMs??250,c=e.maxDelayMs??3e4,S=e.healthyWindowMs??5e3;let a=null,t=0,r=null,o=null,l=null,d=!1,v=!1,n=!1,u=null,w=!1;function E(){a?.close(),a=null,o!==null&&clearTimeout(o),o=null}function f(m,i){n||(w=!0,t>0&&o===null&&(o=setTimeout(()=>{t=0,o=null},S)),m(i),e.resumeFromLastEventId&&i.lastEventId&&(u=i.lastEventId))}function b(m=!1){if(n)return;E(),w=!1;const i=e.url();a=new EventSource(m?g(i,"last_event_id",u):i),e.resumeFromLastEventId&&(a.onopen=()=>{w=!0}),a.onmessage=s=>f(e.onMessage,s),a.onerror=()=>{if(!n){try{e.onError?.()}catch{}h()}};for(const[s,T]of Object.entries(e.onEvents??{}))a.addEventListener(s,I=>f(T,I))}async function h(m=!0){if(d||n)return;d=!0;const i=!!(e.resumeFromLastEventId&&u!==null&&w);E();try{if(m&&t>0&&await new Promise(s=>{l=s,r=setTimeout(()=>{r=null,l=null,s()},t)}),n)return;t=Math.min(t===0?y:t*2,c),i||(u=null,await e.onBeforeReconnect?.()),n||b(i)}catch{if(!n){r=setTimeout(()=>{r=null,d=!1,h(!1)},t);return}}finally{(r===null||l!==null)&&(d=!1)}}return{start(){v||n||(v=!0,b())},stop(){n||(n=!0,E(),r!==null&&clearTimeout(r),r=null,l?.(),l=null)}}}function g(e,y,c){return`${e}${e.includes("?")?"&":"?"}${y}=${encodeURIComponent(c)}`}function p(e){const y=e.resolveUrl??(n=>n);let c=null,S=null,a=null,t=null,r=null,o=null,l=!1;function d(n){return new Promise(u=>{r=u,t=setTimeout(()=>{t=null,r=null,u()},n)})}async function v(n){let u=e.initialSnapshotRetryMs??250;const w=e.maxSnapshotRetryMs??3e4;let E=!1;for(;!l;){const f=new AbortController;a=f;try{const b=c===null?e.snapshotPath:g(e.snapshotPath,"sub",c),h=await fetch(y(b),{signal:f.signal});if(!h.ok)throw new Error(`HTTP ${h.status}`);const m=await h.json(),i=h.headers.get("x-sub-id")?.trim();if(!i)throw new Error("Snapshot response missing non-empty x-sub-id");const s=e.retrySnapshotAfter?.(m)??null;if(s!==null){if(!Number.isFinite(s)||s<0)throw new Error("Invalid snapshot retry delay");await d(s);continue}if(l||f.signal.aborted)return;c=i,e.applySnapshot(m);return}catch(b){if(l||f.signal.aborted)return;try{e.onSnapshotError?.(b)}catch{}E||n?.(),E=!0,await d(u),u=Math.min(u*2,w)}finally{a===f&&(a=null)}}}return{start(){return o!==null?o:l?Promise.resolve():(o=new Promise(n=>{(async()=>(await v(n),!l&&c!==null&&(S=M({url:()=>y(g(e.streamPath,"sub",c)),onMessage:e.onMessage,resumeFromLastEventId:!0,...e.onStreamError?{onError:e.onStreamError}:{},onBeforeReconnect:()=>v()}),S.start()),n()))()}),o)},stop(){l||(l=!0,a?.abort(),S?.stop(),t!==null&&clearTimeout(t),t=null,r?.(),r=null)}}}exports.createResilientSse=M;exports.createSnapshotCursorSse=p;
|
|
2
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../src/index.ts"],"sourcesContent":["export interface ResilientSseOptions {\n url: () => string;\n onMessage: (event: MessageEvent) => void;\n onEvents?: Record<string, (event: MessageEvent) => void>;\n onBeforeReconnect?: () => void | Promise<void>;\n resumeFromLastEventId?: boolean;\n onError?: () => void;\n initialDelayMs?: number;\n maxDelayMs?: number;\n healthyWindowMs?: number;\n}\n\nexport interface ResilientSseSubscription {\n start(): void;\n stop(): void;\n}\n\nexport function createResilientSse(opts: ResilientSseOptions): ResilientSseSubscription {\n const initial = opts.initialDelayMs ?? 250;\n const maximum = opts.maxDelayMs ?? 30_000;\n const healthyWindow = opts.healthyWindowMs ?? 5_000;\n let source: EventSource | null = null;\n let delay = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let healthyTimer: ReturnType<typeof setTimeout> | null = null;\n let wake: (() => void) | null = null;\n let running = false;\n let started = false;\n let stopped = false;\n let lastEventId: string | null = null;\n let opened = false;\n\n function close(): void {\n source?.close();\n source = null;\n if (healthyTimer !== null) clearTimeout(healthyTimer);\n healthyTimer = null;\n }\n\n function receive(callback: (event: MessageEvent) => void, event: MessageEvent): void {\n if (stopped) return;\n opened = true;\n if (delay > 0 && healthyTimer === null) {\n healthyTimer = setTimeout(() => {\n delay = 0;\n healthyTimer = null;\n }, healthyWindow);\n }\n callback(event);\n if (opts.resumeFromLastEventId && event.lastEventId) lastEventId = event.lastEventId;\n }\n\n function open(resume = false): void {\n if (stopped) return;\n close();\n opened = false;\n const url = opts.url();\n source = new EventSource(resume ? joinQuery(url, \"last_event_id\", lastEventId!) : url);\n if (opts.resumeFromLastEventId) source.onopen = () => { opened = true; };\n source.onmessage = (event) => receive(opts.onMessage, event);\n source.onerror = () => {\n if (stopped) return;\n try {\n opts.onError?.();\n } catch {\n // Notification failures must not interrupt recovery.\n }\n void reconnect();\n };\n for (const [name, callback] of Object.entries(opts.onEvents ?? {})) {\n source.addEventListener(name, (event) => receive(callback, event as MessageEvent));\n }\n }\n\n async function reconnect(waitBeforeAttempt = true): Promise<void> {\n if (running || stopped) return;\n running = true;\n const resume = Boolean(opts.resumeFromLastEventId && lastEventId !== null && opened);\n close();\n try {\n if (waitBeforeAttempt && delay > 0) {\n await new Promise<void>((resolve) => {\n wake = resolve;\n timer = setTimeout(() => {\n timer = null;\n wake = null;\n resolve();\n }, delay);\n });\n }\n if (stopped) return;\n delay = Math.min(delay === 0 ? initial : delay * 2, maximum);\n if (!resume) {\n lastEventId = null;\n await opts.onBeforeReconnect?.();\n }\n if (!stopped) open(resume);\n } catch {\n if (!stopped) {\n timer = setTimeout(() => {\n timer = null;\n running = false;\n void reconnect(false);\n }, delay);\n return;\n }\n } finally {\n if (timer === null || wake !== null) running = false;\n }\n }\n\n return {\n start(): void {\n if (started || stopped) return;\n started = true;\n open();\n },\n stop(): void {\n if (stopped) return;\n stopped = true;\n close();\n if (timer !== null) clearTimeout(timer);\n timer = null;\n wake?.();\n wake = null;\n },\n };\n}\n\nexport interface SnapshotCursorSseOptions<S> {\n snapshotPath: string;\n streamPath: string;\n resolveUrl?: (path: string) => string;\n applySnapshot: (body: S) => void;\n onMessage: (event: MessageEvent) => void;\n retrySnapshotAfter?: (body: S) => number | null;\n onSnapshotError?: (reason: unknown) => void;\n onStreamError?: () => void;\n initialSnapshotRetryMs?: number;\n maxSnapshotRetryMs?: number;\n}\n\nexport interface SnapshotCursorSseChannel {\n start(): Promise<void>;\n stop(): void;\n}\n\nfunction joinQuery(path: string, key: string, value: string): string {\n return `${path}${path.includes(\"?\") ? \"&\" : \"?\"}${key}=${encodeURIComponent(value)}`;\n}\n\nexport function createSnapshotCursorSse<S>(opts: SnapshotCursorSseOptions<S>): SnapshotCursorSseChannel {\n const resolveUrl = opts.resolveUrl ?? ((path: string) => path);\n let subId: string | null = null;\n let subscription: ResilientSseSubscription | null = null;\n let controller: AbortController | null = null;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let wake: (() => void) | null = null;\n let started: Promise<void> | null = null;\n let stopped = false;\n\n function wait(milliseconds: number): Promise<void> {\n return new Promise<void>((resolve) => {\n wake = resolve;\n timer = setTimeout(() => {\n timer = null;\n wake = null;\n resolve();\n }, milliseconds);\n });\n }\n\n async function snapshot(onFirstFailure?: () => void): Promise<void> {\n let delay = opts.initialSnapshotRetryMs ?? 250;\n const maxDelay = opts.maxSnapshotRetryMs ?? 30_000;\n let failed = false;\n while (!stopped) {\n const abort = new AbortController();\n controller = abort;\n try {\n const path = subId === null ? opts.snapshotPath : joinQuery(opts.snapshotPath, \"sub\", subId);\n const response = await fetch(resolveUrl(path), { signal: abort.signal });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n const body = (await response.json()) as S;\n const id = response.headers.get(\"x-sub-id\")?.trim();\n if (!id) throw new Error(\"Snapshot response missing non-empty x-sub-id\");\n const retry = opts.retrySnapshotAfter?.(body) ?? null;\n if (retry !== null) {\n if (!Number.isFinite(retry) || retry < 0) throw new Error(\"Invalid snapshot retry delay\");\n await wait(retry);\n continue;\n }\n if (stopped || abort.signal.aborted) return;\n subId = id;\n opts.applySnapshot(body);\n return;\n } catch (reason) {\n if (stopped || abort.signal.aborted) return;\n try {\n opts.onSnapshotError?.(reason);\n } catch {\n // Notification failures must not interrupt recovery.\n }\n if (!failed) onFirstFailure?.();\n failed = true;\n await wait(delay);\n delay = Math.min(delay * 2, maxDelay);\n } finally {\n if (controller === abort) controller = null;\n }\n }\n }\n\n return {\n start(): Promise<void> {\n if (started !== null) return started;\n if (stopped) return Promise.resolve();\n started = new Promise<void>((resolve) => {\n void (async () => {\n await snapshot(resolve);\n if (!stopped && subId !== null) {\n subscription = createResilientSse({\n url: () => resolveUrl(joinQuery(opts.streamPath, \"sub\", subId!)),\n onMessage: opts.onMessage,\n resumeFromLastEventId: true,\n ...(opts.onStreamError ? { onError: opts.onStreamError } : {}),\n onBeforeReconnect: () => snapshot(),\n });\n subscription.start();\n }\n resolve();\n })();\n });\n return started;\n },\n stop(): void {\n if (stopped) return;\n stopped = true;\n controller?.abort();\n subscription?.stop();\n if (timer !== null) clearTimeout(timer);\n timer = null;\n wake?.();\n wake = null;\n },\n };\n}\n"],"names":["createResilientSse","opts","initial","maximum","healthyWindow","source","delay","timer","healthyTimer","wake","running","started","stopped","lastEventId","opened","close","receive","callback","event","open","resume","url","joinQuery","reconnect","name","waitBeforeAttempt","resolve","path","key","value","createSnapshotCursorSse","resolveUrl","subId","subscription","controller","wait","milliseconds","snapshot","onFirstFailure","maxDelay","failed","abort","response","body","id","retry","reason"],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ResilientSseOptions {
|
|
2
|
+
url: () => string;
|
|
3
|
+
onMessage: (event: MessageEvent) => void;
|
|
4
|
+
onEvents?: Record<string, (event: MessageEvent) => void>;
|
|
5
|
+
onBeforeReconnect?: () => void | Promise<void>;
|
|
6
|
+
resumeFromLastEventId?: boolean;
|
|
7
|
+
onError?: () => void;
|
|
8
|
+
initialDelayMs?: number;
|
|
9
|
+
maxDelayMs?: number;
|
|
10
|
+
healthyWindowMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ResilientSseSubscription {
|
|
13
|
+
start(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function createResilientSse(opts: ResilientSseOptions): ResilientSseSubscription;
|
|
17
|
+
export interface SnapshotCursorSseOptions<S> {
|
|
18
|
+
snapshotPath: string;
|
|
19
|
+
streamPath: string;
|
|
20
|
+
resolveUrl?: (path: string) => string;
|
|
21
|
+
applySnapshot: (body: S) => void;
|
|
22
|
+
onMessage: (event: MessageEvent) => void;
|
|
23
|
+
retrySnapshotAfter?: (body: S) => number | null;
|
|
24
|
+
onSnapshotError?: (reason: unknown) => void;
|
|
25
|
+
onStreamError?: () => void;
|
|
26
|
+
initialSnapshotRetryMs?: number;
|
|
27
|
+
maxSnapshotRetryMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface SnapshotCursorSseChannel {
|
|
30
|
+
start(): Promise<void>;
|
|
31
|
+
stop(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createSnapshotCursorSse<S>(opts: SnapshotCursorSseOptions<S>): SnapshotCursorSseChannel;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
function T(e) {
|
|
2
|
+
const y = e.initialDelayMs ?? 250, c = e.maxDelayMs ?? 3e4, v = e.healthyWindowMs ?? 5e3;
|
|
3
|
+
let a = null, t = 0, r = null, u = null, l = null, d = !1, S = !1, n = !1, o = null, w = !1;
|
|
4
|
+
function E() {
|
|
5
|
+
a?.close(), a = null, u !== null && clearTimeout(u), u = null;
|
|
6
|
+
}
|
|
7
|
+
function f(m, i) {
|
|
8
|
+
n || (w = !0, t > 0 && u === null && (u = setTimeout(() => {
|
|
9
|
+
t = 0, u = null;
|
|
10
|
+
}, v)), m(i), e.resumeFromLastEventId && i.lastEventId && (o = i.lastEventId));
|
|
11
|
+
}
|
|
12
|
+
function b(m = !1) {
|
|
13
|
+
if (n) return;
|
|
14
|
+
E(), w = !1;
|
|
15
|
+
const i = e.url();
|
|
16
|
+
a = new EventSource(m ? g(i, "last_event_id", o) : i), e.resumeFromLastEventId && (a.onopen = () => {
|
|
17
|
+
w = !0;
|
|
18
|
+
}), a.onmessage = (s) => f(e.onMessage, s), a.onerror = () => {
|
|
19
|
+
if (!n) {
|
|
20
|
+
try {
|
|
21
|
+
e.onError?.();
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
h();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (const [s, I] of Object.entries(e.onEvents ?? {}))
|
|
28
|
+
a.addEventListener(s, (M) => f(I, M));
|
|
29
|
+
}
|
|
30
|
+
async function h(m = !0) {
|
|
31
|
+
if (d || n) return;
|
|
32
|
+
d = !0;
|
|
33
|
+
const i = !!(e.resumeFromLastEventId && o !== null && w);
|
|
34
|
+
E();
|
|
35
|
+
try {
|
|
36
|
+
if (m && t > 0 && await new Promise((s) => {
|
|
37
|
+
l = s, r = setTimeout(() => {
|
|
38
|
+
r = null, l = null, s();
|
|
39
|
+
}, t);
|
|
40
|
+
}), n) return;
|
|
41
|
+
t = Math.min(t === 0 ? y : t * 2, c), i || (o = null, await e.onBeforeReconnect?.()), n || b(i);
|
|
42
|
+
} catch {
|
|
43
|
+
if (!n) {
|
|
44
|
+
r = setTimeout(() => {
|
|
45
|
+
r = null, d = !1, h(!1);
|
|
46
|
+
}, t);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
(r === null || l !== null) && (d = !1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
start() {
|
|
55
|
+
S || n || (S = !0, b());
|
|
56
|
+
},
|
|
57
|
+
stop() {
|
|
58
|
+
n || (n = !0, E(), r !== null && clearTimeout(r), r = null, l?.(), l = null);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function g(e, y, c) {
|
|
63
|
+
return `${e}${e.includes("?") ? "&" : "?"}${y}=${encodeURIComponent(c)}`;
|
|
64
|
+
}
|
|
65
|
+
function p(e) {
|
|
66
|
+
const y = e.resolveUrl ?? ((n) => n);
|
|
67
|
+
let c = null, v = null, a = null, t = null, r = null, u = null, l = !1;
|
|
68
|
+
function d(n) {
|
|
69
|
+
return new Promise((o) => {
|
|
70
|
+
r = o, t = setTimeout(() => {
|
|
71
|
+
t = null, r = null, o();
|
|
72
|
+
}, n);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function S(n) {
|
|
76
|
+
let o = e.initialSnapshotRetryMs ?? 250;
|
|
77
|
+
const w = e.maxSnapshotRetryMs ?? 3e4;
|
|
78
|
+
let E = !1;
|
|
79
|
+
for (; !l; ) {
|
|
80
|
+
const f = new AbortController();
|
|
81
|
+
a = f;
|
|
82
|
+
try {
|
|
83
|
+
const b = c === null ? e.snapshotPath : g(e.snapshotPath, "sub", c), h = await fetch(y(b), { signal: f.signal });
|
|
84
|
+
if (!h.ok) throw new Error(`HTTP ${h.status}`);
|
|
85
|
+
const m = await h.json(), i = h.headers.get("x-sub-id")?.trim();
|
|
86
|
+
if (!i) throw new Error("Snapshot response missing non-empty x-sub-id");
|
|
87
|
+
const s = e.retrySnapshotAfter?.(m) ?? null;
|
|
88
|
+
if (s !== null) {
|
|
89
|
+
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid snapshot retry delay");
|
|
90
|
+
await d(s);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (l || f.signal.aborted) return;
|
|
94
|
+
c = i, e.applySnapshot(m);
|
|
95
|
+
return;
|
|
96
|
+
} catch (b) {
|
|
97
|
+
if (l || f.signal.aborted) return;
|
|
98
|
+
try {
|
|
99
|
+
e.onSnapshotError?.(b);
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
E || n?.(), E = !0, await d(o), o = Math.min(o * 2, w);
|
|
103
|
+
} finally {
|
|
104
|
+
a === f && (a = null);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
start() {
|
|
110
|
+
return u !== null ? u : l ? Promise.resolve() : (u = new Promise((n) => {
|
|
111
|
+
(async () => (await S(n), !l && c !== null && (v = T({
|
|
112
|
+
url: () => y(g(e.streamPath, "sub", c)),
|
|
113
|
+
onMessage: e.onMessage,
|
|
114
|
+
resumeFromLastEventId: !0,
|
|
115
|
+
...e.onStreamError ? { onError: e.onStreamError } : {},
|
|
116
|
+
onBeforeReconnect: () => S()
|
|
117
|
+
}), v.start()), n()))();
|
|
118
|
+
}), u);
|
|
119
|
+
},
|
|
120
|
+
stop() {
|
|
121
|
+
l || (l = !0, a?.abort(), v?.stop(), t !== null && clearTimeout(t), t = null, r?.(), r = null);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
T as createResilientSse,
|
|
127
|
+
p as createSnapshotCursorSse
|
|
128
|
+
};
|
|
129
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["export interface ResilientSseOptions {\n url: () => string;\n onMessage: (event: MessageEvent) => void;\n onEvents?: Record<string, (event: MessageEvent) => void>;\n onBeforeReconnect?: () => void | Promise<void>;\n resumeFromLastEventId?: boolean;\n onError?: () => void;\n initialDelayMs?: number;\n maxDelayMs?: number;\n healthyWindowMs?: number;\n}\n\nexport interface ResilientSseSubscription {\n start(): void;\n stop(): void;\n}\n\nexport function createResilientSse(opts: ResilientSseOptions): ResilientSseSubscription {\n const initial = opts.initialDelayMs ?? 250;\n const maximum = opts.maxDelayMs ?? 30_000;\n const healthyWindow = opts.healthyWindowMs ?? 5_000;\n let source: EventSource | null = null;\n let delay = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let healthyTimer: ReturnType<typeof setTimeout> | null = null;\n let wake: (() => void) | null = null;\n let running = false;\n let started = false;\n let stopped = false;\n let lastEventId: string | null = null;\n let opened = false;\n\n function close(): void {\n source?.close();\n source = null;\n if (healthyTimer !== null) clearTimeout(healthyTimer);\n healthyTimer = null;\n }\n\n function receive(callback: (event: MessageEvent) => void, event: MessageEvent): void {\n if (stopped) return;\n opened = true;\n if (delay > 0 && healthyTimer === null) {\n healthyTimer = setTimeout(() => {\n delay = 0;\n healthyTimer = null;\n }, healthyWindow);\n }\n callback(event);\n if (opts.resumeFromLastEventId && event.lastEventId) lastEventId = event.lastEventId;\n }\n\n function open(resume = false): void {\n if (stopped) return;\n close();\n opened = false;\n const url = opts.url();\n source = new EventSource(resume ? joinQuery(url, \"last_event_id\", lastEventId!) : url);\n if (opts.resumeFromLastEventId) source.onopen = () => { opened = true; };\n source.onmessage = (event) => receive(opts.onMessage, event);\n source.onerror = () => {\n if (stopped) return;\n try {\n opts.onError?.();\n } catch {\n // Notification failures must not interrupt recovery.\n }\n void reconnect();\n };\n for (const [name, callback] of Object.entries(opts.onEvents ?? {})) {\n source.addEventListener(name, (event) => receive(callback, event as MessageEvent));\n }\n }\n\n async function reconnect(waitBeforeAttempt = true): Promise<void> {\n if (running || stopped) return;\n running = true;\n const resume = Boolean(opts.resumeFromLastEventId && lastEventId !== null && opened);\n close();\n try {\n if (waitBeforeAttempt && delay > 0) {\n await new Promise<void>((resolve) => {\n wake = resolve;\n timer = setTimeout(() => {\n timer = null;\n wake = null;\n resolve();\n }, delay);\n });\n }\n if (stopped) return;\n delay = Math.min(delay === 0 ? initial : delay * 2, maximum);\n if (!resume) {\n lastEventId = null;\n await opts.onBeforeReconnect?.();\n }\n if (!stopped) open(resume);\n } catch {\n if (!stopped) {\n timer = setTimeout(() => {\n timer = null;\n running = false;\n void reconnect(false);\n }, delay);\n return;\n }\n } finally {\n if (timer === null || wake !== null) running = false;\n }\n }\n\n return {\n start(): void {\n if (started || stopped) return;\n started = true;\n open();\n },\n stop(): void {\n if (stopped) return;\n stopped = true;\n close();\n if (timer !== null) clearTimeout(timer);\n timer = null;\n wake?.();\n wake = null;\n },\n };\n}\n\nexport interface SnapshotCursorSseOptions<S> {\n snapshotPath: string;\n streamPath: string;\n resolveUrl?: (path: string) => string;\n applySnapshot: (body: S) => void;\n onMessage: (event: MessageEvent) => void;\n retrySnapshotAfter?: (body: S) => number | null;\n onSnapshotError?: (reason: unknown) => void;\n onStreamError?: () => void;\n initialSnapshotRetryMs?: number;\n maxSnapshotRetryMs?: number;\n}\n\nexport interface SnapshotCursorSseChannel {\n start(): Promise<void>;\n stop(): void;\n}\n\nfunction joinQuery(path: string, key: string, value: string): string {\n return `${path}${path.includes(\"?\") ? \"&\" : \"?\"}${key}=${encodeURIComponent(value)}`;\n}\n\nexport function createSnapshotCursorSse<S>(opts: SnapshotCursorSseOptions<S>): SnapshotCursorSseChannel {\n const resolveUrl = opts.resolveUrl ?? ((path: string) => path);\n let subId: string | null = null;\n let subscription: ResilientSseSubscription | null = null;\n let controller: AbortController | null = null;\n let timer: ReturnType<typeof setTimeout> | null = null;\n let wake: (() => void) | null = null;\n let started: Promise<void> | null = null;\n let stopped = false;\n\n function wait(milliseconds: number): Promise<void> {\n return new Promise<void>((resolve) => {\n wake = resolve;\n timer = setTimeout(() => {\n timer = null;\n wake = null;\n resolve();\n }, milliseconds);\n });\n }\n\n async function snapshot(onFirstFailure?: () => void): Promise<void> {\n let delay = opts.initialSnapshotRetryMs ?? 250;\n const maxDelay = opts.maxSnapshotRetryMs ?? 30_000;\n let failed = false;\n while (!stopped) {\n const abort = new AbortController();\n controller = abort;\n try {\n const path = subId === null ? opts.snapshotPath : joinQuery(opts.snapshotPath, \"sub\", subId);\n const response = await fetch(resolveUrl(path), { signal: abort.signal });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n const body = (await response.json()) as S;\n const id = response.headers.get(\"x-sub-id\")?.trim();\n if (!id) throw new Error(\"Snapshot response missing non-empty x-sub-id\");\n const retry = opts.retrySnapshotAfter?.(body) ?? null;\n if (retry !== null) {\n if (!Number.isFinite(retry) || retry < 0) throw new Error(\"Invalid snapshot retry delay\");\n await wait(retry);\n continue;\n }\n if (stopped || abort.signal.aborted) return;\n subId = id;\n opts.applySnapshot(body);\n return;\n } catch (reason) {\n if (stopped || abort.signal.aborted) return;\n try {\n opts.onSnapshotError?.(reason);\n } catch {\n // Notification failures must not interrupt recovery.\n }\n if (!failed) onFirstFailure?.();\n failed = true;\n await wait(delay);\n delay = Math.min(delay * 2, maxDelay);\n } finally {\n if (controller === abort) controller = null;\n }\n }\n }\n\n return {\n start(): Promise<void> {\n if (started !== null) return started;\n if (stopped) return Promise.resolve();\n started = new Promise<void>((resolve) => {\n void (async () => {\n await snapshot(resolve);\n if (!stopped && subId !== null) {\n subscription = createResilientSse({\n url: () => resolveUrl(joinQuery(opts.streamPath, \"sub\", subId!)),\n onMessage: opts.onMessage,\n resumeFromLastEventId: true,\n ...(opts.onStreamError ? { onError: opts.onStreamError } : {}),\n onBeforeReconnect: () => snapshot(),\n });\n subscription.start();\n }\n resolve();\n })();\n });\n return started;\n },\n stop(): void {\n if (stopped) return;\n stopped = true;\n controller?.abort();\n subscription?.stop();\n if (timer !== null) clearTimeout(timer);\n timer = null;\n wake?.();\n wake = null;\n },\n };\n}\n"],"names":["createResilientSse","opts","initial","maximum","healthyWindow","source","delay","timer","healthyTimer","wake","running","started","stopped","lastEventId","opened","close","receive","callback","event","open","resume","url","joinQuery","reconnect","name","waitBeforeAttempt","resolve","path","key","value","createSnapshotCursorSse","resolveUrl","subId","subscription","controller","wait","milliseconds","snapshot","onFirstFailure","maxDelay","failed","abort","response","body","id","retry","reason"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0k/kal-sse-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Framework-agnostic browser client for kal-sse snapshot and server-owned cursor channels.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sse",
|
|
7
|
+
"server-sent-events",
|
|
8
|
+
"eventsource",
|
|
9
|
+
"kal-sse",
|
|
10
|
+
"reconnect",
|
|
11
|
+
"resume",
|
|
12
|
+
"snapshot"
|
|
13
|
+
],
|
|
14
|
+
"author": "Valentin Lab <valentin.lab@kalysto.org>",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/0k/kal-sse-client",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/0k/kal-sse-client.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/0k/kal-sse-client/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js",
|
|
37
|
+
"require": "./dist/index.cjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"prepack": "npm run build",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"build": "tsc -p tsconfig.build.json && vite build",
|
|
48
|
+
"check": "npm run test && npm run typecheck && npm run build"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "22.10.5",
|
|
52
|
+
"jsdom": "25.0.1",
|
|
53
|
+
"typescript": "5.7.3",
|
|
54
|
+
"vite": "6.4.4",
|
|
55
|
+
"vitest": "4.1.11"
|
|
56
|
+
}
|
|
57
|
+
}
|