@0k/kal-sse-client 0.2.0 → 0.3.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/README.md CHANGED
@@ -22,6 +22,9 @@ Chrome 91 and later.
22
22
  npm install @0k/kal-sse-client
23
23
  ```
24
24
 
25
+ The package is an ES module only: load it with `import`, as bundlers do.
26
+ There is no CommonJS build for `require()`.
27
+
25
28
  ## Usage
26
29
 
27
30
  ### Snapshot and cursor channel
@@ -130,27 +133,26 @@ back to the server cursor instead of client-directed replay.
130
133
 
131
134
  ### `createSnapshotCursorSse<S>(options)`
132
135
 
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. |
136
+ | Option | Type | Default | Meaning |
137
+ |--------------------------|---------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
138
+ | `snapshotPath` | `string` | Required | Snapshot endpoint path. After receiving a subscription id, later requests add `sub`. |
139
+ | `streamPath` | `string` | Required | SSE endpoint path. The client adds the server-owned `sub` id. |
140
+ | `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. |
141
+ | `applySnapshot` | `(body: S) => void` | Required | Applies a successful snapshot after its non-empty `x-sub-id` header is read. |
142
+ | `onMessage` | `(event: MessageEvent) => void` | Required | Handles stream messages. Parse the event payload and update state here. |
143
+ | `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. |
144
+ | `onSnapshotError` | `(reason: unknown) => void` | None | Receives failed snapshot fetch, HTTP, header, JSON, callback, or retry-delay validation errors before snapshot retry. An HTTP refusal is a `SnapshotHttpError` with the response `status`; its message is the body's `detail` or `error` string when the server gave one, else `HTTP <status>`. Exceptions from this notification are swallowed. |
145
+ | `onStreamError` | `() => void` | None | Receives errors reported by the underlying stream before its reconnect logic runs. Exceptions from this notification are swallowed. |
146
+ | `initialSnapshotRetryMs` | `number` | `250` | First retry delay for a failed snapshot in milliseconds. |
147
+ | `maxSnapshotRetryMs` | `number` | `30000` | Maximum snapshot retry delay in milliseconds. |
145
148
 
146
149
  ## Development
147
150
 
148
- Run the checks separately when working on the package:
151
+ Run every check (tests, typecheck, build, and the Chrome 91 inspection
152
+ of the built code) before delivering a change:
149
153
 
150
154
  ``` bash
151
- npm test
152
- npm run typecheck
153
- npm run build
155
+ npm run check
154
156
  ```
155
157
 
156
158
  For a local consumer, run `npm link` in this repository, then run
@@ -166,6 +168,37 @@ MIT, see `LICENSE`.
166
168
  ## Changelog
167
169
 
168
170
 
171
+ ### 0.3.0 (2026-11-06)
172
+
173
+ #### New
174
+
175
+ * Report a refused snapshot as ``SnapshotHttpError`` with the server's reason. [Valentin Lab]
176
+
177
+ A refused snapshot used to reach ``onSnapshotError`` as a bare
178
+ ``HTTP <status>`` error, dropping what the server said about why
179
+ (``path does not exist: ...``, ``permission denied``), so a host could
180
+ not show its user the actual reason.
181
+
182
+ It now raises the exported ``SnapshotHttpError``, which carries the
183
+ response ``status`` and, as its message, the JSON body's ``detail`` or
184
+ ``error`` string when the server gave one, else ``HTTP <status>`` as
185
+ before.
186
+
187
+ #### Changes
188
+
189
+ * Ship ESM only, compiled by ``tsc`` without minification. [Valentin Lab]
190
+
191
+ The package now ships a single ES module, ``dist/index.js``, with its
192
+ ``dist/index.d.ts`` types. The code is plain ``tsc`` output for
193
+ ES2020, readable and close to the source, so stack traces and reviews
194
+ of the installed package match ``src/``. It still runs on Chrome 91.
195
+
196
+ BREAKING CHANGE: the CommonJS build is gone. ``require()`` of the
197
+ package, ``dist/index.cjs``, the ``main`` and ``module`` fields and
198
+ the bundled source maps are removed. Load it with ``import``, as any
199
+ bundler (Vite, webpack, Rollup, esbuild) already does.
200
+
201
+
169
202
  ### 0.2.0 (2026-10-10)
170
203
 
171
204
  #### New
package/dist/index.d.ts CHANGED
@@ -14,6 +14,15 @@ export interface ResilientSseSubscription {
14
14
  stop(): void;
15
15
  }
16
16
  export declare function createResilientSse(opts: ResilientSseOptions): ResilientSseSubscription;
17
+ /**
18
+ * A snapshot request the server refused. The message is the server's own
19
+ * reason (the JSON body's `detail` or `error` string) when it gave one,
20
+ * else `HTTP <status>`.
21
+ */
22
+ export declare class SnapshotHttpError extends Error {
23
+ readonly status: number;
24
+ constructor(status: number, reason: string | null);
25
+ }
17
26
  export interface SnapshotCursorSseOptions<S> {
18
27
  snapshotPath: string;
19
28
  streamPath: string;
package/dist/index.js CHANGED
@@ -1,129 +1,266 @@
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) {
1
+ export function createResilientSse(opts) {
2
+ const initial = opts.initialDelayMs ?? 250;
3
+ const maximum = opts.maxDelayMs ?? 30000;
4
+ const healthyWindow = opts.healthyWindowMs ?? 5000;
5
+ let source = null;
6
+ let delay = 0;
7
+ let timer = null;
8
+ let healthyTimer = null;
9
+ let wake = null;
10
+ let running = false;
11
+ let started = false;
12
+ let stopped = false;
13
+ let lastEventId = null;
14
+ let opened = false;
15
+ function close() {
16
+ source?.close();
17
+ source = null;
18
+ if (healthyTimer !== null)
19
+ clearTimeout(healthyTimer);
20
+ healthyTimer = null;
21
+ }
22
+ function receive(callback, event) {
23
+ if (stopped)
24
+ return;
25
+ opened = true;
26
+ if (delay > 0 && healthyTimer === null) {
27
+ healthyTimer = setTimeout(() => {
28
+ delay = 0;
29
+ healthyTimer = null;
30
+ }, healthyWindow);
31
+ }
32
+ callback(event);
33
+ if (opts.resumeFromLastEventId && event.lastEventId)
34
+ lastEventId = event.lastEventId;
35
+ }
36
+ function open(resume = false) {
37
+ if (stopped)
38
+ return;
39
+ close();
40
+ opened = false;
41
+ const url = opts.url();
42
+ source = new EventSource(resume ? joinQuery(url, "last_event_id", lastEventId) : url);
43
+ if (opts.resumeFromLastEventId)
44
+ source.onopen = () => { opened = true; };
45
+ source.onmessage = (event) => receive(opts.onMessage, event);
46
+ source.onerror = () => {
47
+ if (stopped)
48
+ return;
49
+ try {
50
+ opts.onError?.();
51
+ }
52
+ catch {
53
+ // Notification failures must not interrupt recovery.
54
+ }
55
+ void reconnect();
56
+ };
57
+ for (const [name, callback] of Object.entries(opts.onEvents ?? {})) {
58
+ source.addEventListener(name, (event) => receive(callback, event));
59
+ }
60
+ }
61
+ async function reconnect(waitBeforeAttempt = true) {
62
+ if (running || stopped)
63
+ return;
64
+ running = true;
65
+ const resume = Boolean(opts.resumeFromLastEventId && lastEventId !== null && opened);
66
+ close();
20
67
  try {
21
- e.onError?.();
22
- } catch {
68
+ if (waitBeforeAttempt && delay > 0) {
69
+ await new Promise((resolve) => {
70
+ wake = resolve;
71
+ timer = setTimeout(() => {
72
+ timer = null;
73
+ wake = null;
74
+ resolve();
75
+ }, delay);
76
+ });
77
+ }
78
+ if (stopped)
79
+ return;
80
+ delay = Math.min(delay === 0 ? initial : delay * 2, maximum);
81
+ if (!resume) {
82
+ lastEventId = null;
83
+ await opts.onBeforeReconnect?.();
84
+ }
85
+ if (!stopped)
86
+ open(resume);
23
87
  }
24
- h();
25
- }
88
+ catch {
89
+ if (!stopped) {
90
+ timer = setTimeout(() => {
91
+ timer = null;
92
+ running = false;
93
+ void reconnect(false);
94
+ }, delay);
95
+ return;
96
+ }
97
+ }
98
+ finally {
99
+ if (timer === null || wake !== null)
100
+ running = false;
101
+ }
102
+ }
103
+ return {
104
+ start() {
105
+ if (started || stopped)
106
+ return;
107
+ started = true;
108
+ open();
109
+ },
110
+ stop() {
111
+ if (stopped)
112
+ return;
113
+ stopped = true;
114
+ close();
115
+ if (timer !== null)
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ wake?.();
119
+ wake = null;
120
+ },
26
121
  };
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();
122
+ }
123
+ /**
124
+ * A snapshot request the server refused. The message is the server's own
125
+ * reason (the JSON body's `detail` or `error` string) when it gave one,
126
+ * else `HTTP <status>`.
127
+ */
128
+ export class SnapshotHttpError extends Error {
129
+ constructor(status, reason) {
130
+ super(reason ?? `HTTP ${status}`);
131
+ Object.defineProperty(this, "status", {
132
+ enumerable: true,
133
+ configurable: true,
134
+ writable: true,
135
+ value: void 0
136
+ });
137
+ this.name = "SnapshotHttpError";
138
+ this.status = status;
139
+ }
140
+ }
141
+ async function refusal(response) {
142
+ let reason = null;
35
143
  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);
144
+ const body = (await response.json());
145
+ for (const text of [body?.detail, body?.error]) {
146
+ if (typeof text === "string" && text.trim()) {
147
+ reason = text;
148
+ break;
149
+ }
150
+ }
51
151
  }
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);
152
+ catch {
153
+ // Not JSON: only the status is known.
59
154
  }
60
- };
155
+ return new SnapshotHttpError(response.status, reason);
61
156
  }
62
- function g(e, y, c) {
63
- return `${e}${e.includes("?") ? "&" : "?"}${y}=${encodeURIComponent(c)}`;
157
+ function joinQuery(path, key, value) {
158
+ return `${path}${path.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
64
159
  }
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
- }
160
+ export function createSnapshotCursorSse(opts) {
161
+ const resolveUrl = opts.resolveUrl ?? ((path) => path);
162
+ let subId = null;
163
+ let subscription = null;
164
+ let controller = null;
165
+ let timer = null;
166
+ let wake = null;
167
+ let started = null;
168
+ let stopped = false;
169
+ function wait(milliseconds) {
170
+ return new Promise((resolve) => {
171
+ wake = resolve;
172
+ timer = setTimeout(() => {
173
+ timer = null;
174
+ wake = null;
175
+ resolve();
176
+ }, milliseconds);
177
+ });
106
178
  }
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);
179
+ async function snapshot(onFirstFailure) {
180
+ let delay = opts.initialSnapshotRetryMs ?? 250;
181
+ const maxDelay = opts.maxSnapshotRetryMs ?? 30000;
182
+ let failed = false;
183
+ while (!stopped) {
184
+ const abort = new AbortController();
185
+ controller = abort;
186
+ try {
187
+ const path = subId === null ? opts.snapshotPath : joinQuery(opts.snapshotPath, "sub", subId);
188
+ const response = await fetch(resolveUrl(path), { signal: abort.signal });
189
+ if (!response.ok)
190
+ throw await refusal(response);
191
+ const body = (await response.json());
192
+ const id = response.headers.get("x-sub-id")?.trim();
193
+ if (!id)
194
+ throw new Error("Snapshot response missing non-empty x-sub-id");
195
+ const retry = opts.retrySnapshotAfter?.(body) ?? null;
196
+ if (retry !== null) {
197
+ if (!Number.isFinite(retry) || retry < 0)
198
+ throw new Error("Invalid snapshot retry delay");
199
+ await wait(retry);
200
+ continue;
201
+ }
202
+ if (stopped || abort.signal.aborted)
203
+ return;
204
+ subId = id;
205
+ opts.applySnapshot(body);
206
+ return;
207
+ }
208
+ catch (reason) {
209
+ if (stopped || abort.signal.aborted)
210
+ return;
211
+ try {
212
+ opts.onSnapshotError?.(reason);
213
+ }
214
+ catch {
215
+ // Notification failures must not interrupt recovery.
216
+ }
217
+ if (!failed)
218
+ onFirstFailure?.();
219
+ failed = true;
220
+ await wait(delay);
221
+ delay = Math.min(delay * 2, maxDelay);
222
+ }
223
+ finally {
224
+ if (controller === abort)
225
+ controller = null;
226
+ }
227
+ }
122
228
  }
123
- };
229
+ return {
230
+ start() {
231
+ if (started !== null)
232
+ return started;
233
+ if (stopped)
234
+ return Promise.resolve();
235
+ started = new Promise((resolve) => {
236
+ void (async () => {
237
+ await snapshot(resolve);
238
+ if (!stopped && subId !== null) {
239
+ subscription = createResilientSse({
240
+ url: () => resolveUrl(joinQuery(opts.streamPath, "sub", subId)),
241
+ onMessage: opts.onMessage,
242
+ resumeFromLastEventId: true,
243
+ ...(opts.onStreamError ? { onError: opts.onStreamError } : {}),
244
+ onBeforeReconnect: () => snapshot(),
245
+ });
246
+ subscription.start();
247
+ }
248
+ resolve();
249
+ })();
250
+ });
251
+ return started;
252
+ },
253
+ stop() {
254
+ if (stopped)
255
+ return;
256
+ stopped = true;
257
+ controller?.abort();
258
+ subscription?.stop();
259
+ if (timer !== null)
260
+ clearTimeout(timer);
261
+ timer = null;
262
+ wake?.();
263
+ wake = null;
264
+ },
265
+ };
124
266
  }
125
- export {
126
- T as createResilientSse,
127
- p as createSnapshotCursorSse
128
- };
129
- //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0k/kal-sse-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Framework-agnostic browser client for kal-sse snapshot and server-owned cursor channels.",
5
5
  "keywords": [
6
6
  "sse",
@@ -27,31 +27,29 @@
27
27
  "README.md",
28
28
  "LICENSE"
29
29
  ],
30
- "main": "./dist/index.cjs",
31
- "module": "./dist/index.js",
32
30
  "types": "./dist/index.d.ts",
33
31
  "exports": {
34
32
  ".": {
35
33
  "types": "./dist/index.d.ts",
36
- "import": "./dist/index.js",
37
- "require": "./dist/index.cjs"
34
+ "default": "./dist/index.js"
38
35
  }
39
36
  },
40
37
  "publishConfig": {
41
38
  "access": "public"
42
39
  },
43
40
  "scripts": {
44
- "prepack": "npm run build",
41
+ "prepare": "npm run build",
45
42
  "test": "vitest run",
46
43
  "typecheck": "tsc --noEmit",
47
- "build": "tsc -p tsconfig.build.json && vite build",
48
- "check": "npm run test && npm run typecheck && npm run build"
44
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
45
+ "check:chrome91": "node scripts/check-chrome91.mjs",
46
+ "check": "npm run test && npm run typecheck && npm run build && npm run check:chrome91"
49
47
  },
50
48
  "devDependencies": {
51
49
  "@types/node": "22.10.5",
52
50
  "jsdom": "25.0.1",
53
51
  "typescript": "5.7.3",
54
- "vite": "6.4.4",
55
- "vitest": "4.1.11"
52
+ "vite": "6.4.5",
53
+ "vitest": "4.1.12"
56
54
  }
57
55
  }
package/README.org DELETED
@@ -1,159 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
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
@@ -1 +0,0 @@
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.js.map DELETED
@@ -1 +0,0 @@
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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"}