@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 +49 -16
- package/dist/index.d.ts +9 -0
- package/dist/index.js +255 -118
- package/package.json +8 -10
- package/README.org +0 -159
- package/dist/index.cjs +0 -2
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
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
|
|
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
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
157
|
+
function joinQuery(path, key, value) {
|
|
158
|
+
return `${path}${path.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
|
|
64
159
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
41
|
+
"prepare": "npm run build",
|
|
45
42
|
"test": "vitest run",
|
|
46
43
|
"typecheck": "tsc --noEmit",
|
|
47
|
-
"build": "tsc -p tsconfig.build.json
|
|
48
|
-
"check": "
|
|
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.
|
|
55
|
-
"vitest": "4.1.
|
|
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
|
package/dist/index.cjs.map
DELETED
|
@@ -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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"}
|