@meowpanel/api 0.13.0 → 0.15.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.
@@ -8,7 +8,11 @@ const sse_js_1 = require("../../sse.js");
8
8
  *
9
9
  * Emits typed events for console output, power state changes, and resource utilization.
10
10
  * Also maintains a rolling buffer of console output lines in {@link output}, pre-populated
11
- * with recent history fetched from the node on {@link connect}.
11
+ * with recent history fetched from the node on {@link open}.
12
+ *
13
+ * The stream reconnects on its own after the daemon restarts or the network drops. Track it
14
+ * through {@link state} or the `connection_state` event. After a reconnect, console lines the
15
+ * daemon still holds from the gap are appended to {@link output} and emitted as `console_output`.
12
16
  *
13
17
  * Obtain a connection via `Server.createConnection`.
14
18
  *
@@ -31,12 +35,20 @@ const sse_js_1 = require("../../sse.js");
31
35
  class ServerConnection extends reactive_js_1.ReactiveResource {
32
36
  serverId;
33
37
  nodeUrl;
34
- token;
38
+ getToken;
35
39
  /** Maximum number of console output lines retained in {@link ServerConnection.output}. */
36
40
  static maxOutputLines = 1024;
41
+ /** Console history lines fetched from the node on open and after each reconnect. */
42
+ static historyLines = 100;
43
+ /**
44
+ * Whisker sends a keepalive every 20 seconds, so a stream silent for this long is dead even
45
+ * if the socket never closed.
46
+ */
47
+ static idleTimeoutMs = 45_000;
37
48
  listeners = new Map();
38
49
  sse;
39
- connected = false;
50
+ /** Live console lines held back while the post-reconnect history is merged in. */
51
+ pendingOutput;
40
52
  /**
41
53
  * Rolling buffer of console output lines, capped at {@link ServerConnection.maxOutputLines}.
42
54
  *
@@ -47,15 +59,19 @@ class ServerConnection extends reactive_js_1.ReactiveResource {
47
59
  */
48
60
  output = [];
49
61
  /** @internal */
50
- constructor(serverId, nodeUrl, token) {
62
+ constructor(serverId, nodeUrl, getToken) {
51
63
  super();
52
64
  this.serverId = serverId;
53
65
  this.nodeUrl = nodeUrl;
54
- this.token = token;
66
+ this.getToken = getToken;
67
+ }
68
+ /** State of the stream to the daemon. */
69
+ get state() {
70
+ return this.sse?.state ?? 'disconnected';
55
71
  }
56
72
  /** `true` if the SSE connection is currently open. */
57
73
  get isConnected() {
58
- return this.connected;
74
+ return this.state === 'connected';
59
75
  }
60
76
  /**
61
77
  * Fetch console history and open the SSE stream.
@@ -65,36 +81,35 @@ class ServerConnection extends reactive_js_1.ReactiveResource {
65
81
  *
66
82
  * Only one stream is maintained at a time.
67
83
  *
68
- * @param onError Optional callback invoked if the connection drops unexpectedly.
84
+ * @param onError Optional callback invoked each time the stream drops or a connection attempt fails.
69
85
  */
70
86
  async open(onError) {
71
- this.sse?.disconnect(); // close any existing stream
72
- try {
73
- const res = await fetch(`${this.nodeUrl}/api/servers/${this.serverId}/logs?size=100`, { headers: { Authorization: `Bearer ${this.token}` } });
74
- if (res.ok) {
75
- const json = (await res.json());
76
- this.output = json.data ?? [];
77
- this.notify();
78
- }
79
- }
80
- catch {
81
- // History is best-effort - don't block the SSE connection
87
+ this.close();
88
+ const history = await this.fetchHistory();
89
+ if (history) {
90
+ this.output = history;
91
+ this.notify();
82
92
  }
83
93
  const url = `${this.nodeUrl}/api/servers/${this.serverId}/sse`;
84
- this.sse = new sse_js_1.SseConnection(url, this.token);
85
- this.connected = true;
86
- this.sse.connect((event, data) => {
87
- this.dispatch(event, data);
88
- }, (err) => {
89
- this.connected = false;
94
+ const sse = new sse_js_1.SseConnection(url, this.getToken, { idleTimeoutMs: ServerConnection.idleTimeoutMs });
95
+ this.sse = sse;
96
+ let previous = sse.state;
97
+ sse.on('state', (state) => {
98
+ if (state === 'connected' && previous === 'reconnecting')
99
+ this.resyncOutput(sse);
100
+ previous = state;
90
101
  this.notify();
91
- onError?.(err);
102
+ this.emit('connection_state', state);
92
103
  });
104
+ sse.connect((event, data) => this.dispatch(event, data), onError);
93
105
  }
94
106
  /** Close the SSE stream and stop receiving events. */
95
107
  close() {
96
- this.sse?.disconnect();
97
- this.connected = false;
108
+ const sse = this.sse;
109
+ this.sse = undefined;
110
+ this.pendingOutput = undefined;
111
+ sse?.disconnect();
112
+ sse?.removeAllListeners();
98
113
  }
99
114
  /**
100
115
  * Register a handler for a typed server event.
@@ -124,6 +139,47 @@ class ServerConnection extends reactive_js_1.ReactiveResource {
124
139
  removeAllListeners() {
125
140
  this.listeners.clear();
126
141
  }
142
+ async fetchHistory() {
143
+ try {
144
+ const res = await fetch(`${this.nodeUrl}/api/servers/${this.serverId}/logs?size=${ServerConnection.historyLines}`, { headers: { Authorization: `Bearer ${this.getToken()}` } });
145
+ if (!res.ok)
146
+ return undefined;
147
+ const json = (await res.json());
148
+ return json.data ?? [];
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ }
154
+ /**
155
+ * Fill in the console lines printed while the stream was down.
156
+ *
157
+ * Live lines are held back until the history arrives, because the history snapshot may
158
+ * already contain them. Lines that fell out of the daemon's history window are lost.
159
+ */
160
+ async resyncOutput(sse) {
161
+ const pending = [];
162
+ this.pendingOutput = pending;
163
+ const history = await this.fetchHistory();
164
+ if (this.sse !== sse || this.pendingOutput !== pending)
165
+ return;
166
+ this.pendingOutput = undefined;
167
+ const missed = history ? history.slice(overlapLength(this.output, history)) : [];
168
+ const live = history ? pending.slice(overlapLength(history, pending)) : pending;
169
+ for (const line of [...missed, ...live])
170
+ this.appendOutput(line);
171
+ }
172
+ appendOutput(line) {
173
+ this.output.push(line);
174
+ if (this.output.length > ServerConnection.maxOutputLines) {
175
+ this.output.shift();
176
+ }
177
+ this.notify();
178
+ this.emit('console_output', line);
179
+ }
180
+ emit(event, payload) {
181
+ this.listeners.get(event)?.forEach((h) => h(payload));
182
+ }
127
183
  /** Parse and dispatch a raw SSE event to registered handlers. */
128
184
  dispatch(rawEvent, rawData) {
129
185
  // backup_completed is emitted per backup as `backup_completed:<id>`; the id is also in
@@ -139,11 +195,6 @@ class ServerConnection extends reactive_js_1.ReactiveResource {
139
195
  if (typeof parsed !== 'string')
140
196
  return;
141
197
  payload = parsed;
142
- this.output.push(payload);
143
- if (this.output.length > ServerConnection.maxOutputLines) {
144
- this.output.shift();
145
- }
146
- this.notify();
147
198
  break;
148
199
  }
149
200
  case 'status': {
@@ -213,10 +264,10 @@ class ServerConnection extends reactive_js_1.ReactiveResource {
213
264
  break;
214
265
  }
215
266
  case 'backup_completed': {
216
- if (typeof parsed?.uuid !== 'string')
267
+ if (typeof parsed?.id !== 'string')
217
268
  return;
218
269
  payload = {
219
- uuid: parsed.uuid,
270
+ id: parsed.id,
220
271
  isSuccessful: Boolean(parsed.is_successful),
221
272
  checksum: typeof parsed.checksum === 'string' ? parsed.checksum : '',
222
273
  checksumType: typeof parsed.checksum_type === 'string' ? parsed.checksum_type : '',
@@ -231,8 +282,30 @@ class ServerConnection extends reactive_js_1.ReactiveResource {
231
282
  catch {
232
283
  return;
233
284
  }
234
- const handlers = this.listeners.get(event);
235
- handlers?.forEach((h) => h(payload));
285
+ if (event === 'console_output') {
286
+ const line = payload;
287
+ if (this.pendingOutput)
288
+ this.pendingOutput.push(line);
289
+ else
290
+ this.appendOutput(line);
291
+ return;
292
+ }
293
+ this.emit(event, payload);
236
294
  }
237
295
  }
238
296
  exports.ServerConnection = ServerConnection;
297
+ /** Length of the longest suffix of `head` that is also a prefix of `tail`. */
298
+ function overlapLength(head, tail) {
299
+ for (let k = Math.min(head.length, tail.length); k > 0; k--) {
300
+ let match = true;
301
+ for (let i = 0; i < k; i++) {
302
+ if (head[head.length - k + i] !== tail[i]) {
303
+ match = false;
304
+ break;
305
+ }
306
+ }
307
+ if (match)
308
+ return k;
309
+ }
310
+ return 0;
311
+ }
@@ -175,7 +175,7 @@ class Server extends reactive_js_1.ReactiveResource {
175
175
  */
176
176
  async createConnection() {
177
177
  const nodeUrl = await this.resolveNodeUrl();
178
- return new connection_js_1.ServerConnection(this.id, nodeUrl, this.http.getToken());
178
+ return new connection_js_1.ServerConnection(this.id, nodeUrl, () => this.http.getToken());
179
179
  }
180
180
  }
181
181
  exports.Server = Server;
@@ -1,5 +1,33 @@
1
- /** State of an {@link SseConnection}. */
2
- export type SseState = 'disconnected' | 'connecting' | 'connected' | 'error';
1
+ import { TypedEmitter } from './emitter.js';
2
+ /**
3
+ * State of an {@link SseConnection}.
4
+ *
5
+ * `reconnecting` covers both the backoff wait and the retry request. `error` is terminal: the
6
+ * stream was refused with a status a retry cannot fix, or reconnecting is turned off.
7
+ */
8
+ export type SseState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
9
+ /** Events emitted by {@link SseConnection}. */
10
+ export interface SseConnectionEvents {
11
+ /** The connection state changed. */
12
+ state: SseState;
13
+ }
14
+ /** Options for {@link SseConnection}. */
15
+ export interface SseOptions {
16
+ /** Reconnect after the stream drops or a connection attempt fails. Defaults to `true`. */
17
+ reconnect?: boolean;
18
+ /** Delay before the first reconnect attempt. Doubles per failed attempt. Defaults to 1 second. */
19
+ minReconnectDelayMs?: number;
20
+ /** Upper bound for the reconnect delay. Defaults to 30 seconds. */
21
+ maxReconnectDelayMs?: number;
22
+ /**
23
+ * Treat the stream as dead after this long without receiving any bytes, and reconnect.
24
+ *
25
+ * A connection that dies without closing (lost network, dropped NAT entry) otherwise stays
26
+ * `connected` forever. Only set this against a server that sends periodic keepalives,
27
+ * comfortably above their interval. `0` disables it, which is the default.
28
+ */
29
+ idleTimeoutMs?: number;
30
+ }
3
31
  /**
4
32
  * A generic, `fetch`-based Server-Sent Events client.
5
33
  *
@@ -7,9 +35,13 @@ export type SseState = 'disconnected' | 'connecting' | 'connected' | 'error';
7
35
  * header, making it compatible with bearer token auth in all environments
8
36
  * (browser, Deno, Node.js).
9
37
  *
38
+ * Like `EventSource`, it reconnects with exponential backoff whenever the stream ends without
39
+ * {@link disconnect} being called, including a clean close by the server.
40
+ *
10
41
  * @example
11
42
  * ```ts ignore
12
43
  * const sse = new SseConnection('https://panel.example.com/v1/sse', token);
44
+ * sse.on('state', (state) => console.log('SSE is', state));
13
45
  * sse.connect(
14
46
  * (event, data) => console.log(event, data),
15
47
  * (err) => console.error('SSE error:', err),
@@ -18,16 +50,22 @@ export type SseState = 'disconnected' | 'connecting' | 'connected' | 'error';
18
50
  * sse.disconnect();
19
51
  * ```
20
52
  */
21
- export declare class SseConnection {
53
+ export declare class SseConnection extends TypedEmitter<SseConnectionEvents> {
22
54
  private readonly url;
23
55
  private token;
24
56
  private abortController?;
57
+ private reconnectTimer?;
25
58
  private _state;
59
+ private attempt;
60
+ private readonly options;
26
61
  /**
27
- * @param url The full URL to connect to.
28
- * @param token Bearer token used in the `Authorization` header.
62
+ * @param url The full URL to connect to.
63
+ * @param token Bearer token used in the `Authorization` header, or a function returning the
64
+ * current one. A function is read on every attempt, so reconnects pick up a
65
+ * refreshed token.
66
+ * @param options Reconnect and idle-timeout behavior.
29
67
  */
30
- constructor(url: string, token: string);
68
+ constructor(url: string, token: string | (() => string), options?: SseOptions);
31
69
  /** Update the bearer token for subsequent reconnections. */
32
70
  updateToken(token: string): void;
33
71
  /** Current connection state. */
@@ -37,16 +75,30 @@ export declare class SseConnection {
37
75
  /**
38
76
  * Open the SSE connection and begin streaming events.
39
77
  *
40
- * This method is **non-blocking**: it starts the connection and returns immediately
41
- * after the HTTP response headers are received. Events are delivered asynchronously
42
- * via the `onEvent` callback.
78
+ * This method is **non-blocking**: it starts the connection and returns immediately.
79
+ * Events are delivered asynchronously via the `onEvent` callback, and state changes via the
80
+ * `state` event.
43
81
  *
44
82
  * @param onEvent Callback invoked for each received SSE event, with the event name and raw data string.
45
- * @param onError Optional callback invoked if the connection fails or is lost (not called on intentional disconnect).
83
+ * @param onError Optional callback invoked each time a connection attempt fails or an open stream
84
+ * is lost (not called on intentional disconnect). Reconnecting continues unless
85
+ * {@link state} is `error`.
46
86
  */
47
87
  connect(onEvent: (event: string, data: string) => void, onError?: (error: Error) => void): void;
48
- /** Close the SSE connection and stop receiving events. */
88
+ /** Close the SSE connection, cancel any pending reconnect, and stop receiving events. */
49
89
  disconnect(): void;
90
+ private open;
91
+ /**
92
+ * Exponential backoff with jitter, so clients that lost the same server do not all retry at
93
+ * the same instant when it comes back.
94
+ */
95
+ private nextReconnectDelay;
96
+ /**
97
+ * 401 is retryable because the token is re-read on every attempt and may have been refreshed
98
+ * in the meantime. Other client errors (403, 404, ...) will not change on their own.
99
+ */
100
+ private static isRetryableStatus;
101
+ private setState;
50
102
  /** Parse the raw `ReadableStream` body into SSE frames, calling `onEvent` for each complete frame. */
51
103
  private readStream;
52
104
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/src/sse.ts"],"names":[],"mappings":"AAAA,yCAAyC;AACzC,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,CAAC;AAE7E;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,aAAa;IASxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,KAAK;IATd,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,MAAM,CAA4B;IAE1C;;;OAGG;gBAEe,GAAG,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM;IAGtB,4DAA4D;IACrD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAIvC,gCAAgC;IAChC,IAAW,KAAK,IAAI,QAAQ,CAE3B;IAED,mDAAmD;IACnD,IAAW,WAAW,IAAI,OAAO,CAEhC;IAED;;;;;;;;;OASG;IACI,OAAO,CACb,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,EAC9C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC9B,IAAI;IAyCP,0DAA0D;IACnD,UAAU,IAAI,IAAI;IAMzB,sGAAsG;YACxF,UAAU;CAmCxB"}
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/src/sse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAE9F,+CAA+C;AAC/C,MAAM,WAAW,mBAAmB;IACnC,oCAAoC;IACpC,KAAK,EAAE,QAAQ,CAAC;CAChB;AAED,yCAAyC;AACzC,MAAM,WAAW,UAAU;IAC1B,0FAA0F;IAC1F,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,kGAAkG;IAClG,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,mEAAmE;IACnE,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,aAAc,SAAQ,YAAY,CAAC,mBAAmB,CAAC;IAelE,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,KAAK;IAfd,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAgC;IACvD,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAE/C;;;;;;OAMG;gBAEe,GAAG,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,EACtC,OAAO,GAAE,UAAe;IAYzB,4DAA4D;IACrD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAIvC,gCAAgC;IAChC,IAAW,KAAK,IAAI,QAAQ,CAE3B;IAED,mDAAmD;IACnD,IAAW,WAAW,IAAI,OAAO,CAEhC;IAED;;;;;;;;;;;OAWG;IACI,OAAO,CACb,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,EAC9C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC9B,IAAI;IAMP,yFAAyF;IAClF,UAAU,IAAI,IAAI;IAQzB,OAAO,CAAC,IAAI;IA2EZ;;;OAGG;IACH,OAAO,CAAC,kBAAkB;IAO1B;;;OAGG;IACH,OAAO,CAAC,MAAM,CAAC,iBAAiB;IAIhC,OAAO,CAAC,QAAQ;IAMhB,sGAAsG;YACxF,UAAU;CAqCxB"}
package/script/src/sse.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SseConnection = void 0;
4
+ const emitter_js_1 = require("./emitter.js");
4
5
  /**
5
6
  * A generic, `fetch`-based Server-Sent Events client.
6
7
  *
@@ -8,9 +9,13 @@ exports.SseConnection = void 0;
8
9
  * header, making it compatible with bearer token auth in all environments
9
10
  * (browser, Deno, Node.js).
10
11
  *
12
+ * Like `EventSource`, it reconnects with exponential backoff whenever the stream ends without
13
+ * {@link disconnect} being called, including a clean close by the server.
14
+ *
11
15
  * @example
12
16
  * ```ts ignore
13
17
  * const sse = new SseConnection('https://panel.example.com/v1/sse', token);
18
+ * sse.on('state', (state) => console.log('SSE is', state));
14
19
  * sse.connect(
15
20
  * (event, data) => console.log(event, data),
16
21
  * (err) => console.error('SSE error:', err),
@@ -19,18 +24,32 @@ exports.SseConnection = void 0;
19
24
  * sse.disconnect();
20
25
  * ```
21
26
  */
22
- class SseConnection {
27
+ class SseConnection extends emitter_js_1.TypedEmitter {
23
28
  url;
24
29
  token;
25
30
  abortController;
31
+ reconnectTimer;
26
32
  _state = 'disconnected';
33
+ attempt = 0;
34
+ options;
27
35
  /**
28
- * @param url The full URL to connect to.
29
- * @param token Bearer token used in the `Authorization` header.
36
+ * @param url The full URL to connect to.
37
+ * @param token Bearer token used in the `Authorization` header, or a function returning the
38
+ * current one. A function is read on every attempt, so reconnects pick up a
39
+ * refreshed token.
40
+ * @param options Reconnect and idle-timeout behavior.
30
41
  */
31
- constructor(url, token) {
42
+ constructor(url, token, options = {}) {
43
+ super();
32
44
  this.url = url;
33
45
  this.token = token;
46
+ this.options = {
47
+ reconnect: true,
48
+ minReconnectDelayMs: 1_000,
49
+ maxReconnectDelayMs: 30_000,
50
+ idleTimeoutMs: 0,
51
+ ...options,
52
+ };
34
53
  }
35
54
  /** Update the bearer token for subsequent reconnections. */
36
55
  updateToken(token) {
@@ -47,60 +66,121 @@ class SseConnection {
47
66
  /**
48
67
  * Open the SSE connection and begin streaming events.
49
68
  *
50
- * This method is **non-blocking**: it starts the connection and returns immediately
51
- * after the HTTP response headers are received. Events are delivered asynchronously
52
- * via the `onEvent` callback.
69
+ * This method is **non-blocking**: it starts the connection and returns immediately.
70
+ * Events are delivered asynchronously via the `onEvent` callback, and state changes via the
71
+ * `state` event.
53
72
  *
54
73
  * @param onEvent Callback invoked for each received SSE event, with the event name and raw data string.
55
- * @param onError Optional callback invoked if the connection fails or is lost (not called on intentional disconnect).
74
+ * @param onError Optional callback invoked each time a connection attempt fails or an open stream
75
+ * is lost (not called on intentional disconnect). Reconnecting continues unless
76
+ * {@link state} is `error`.
56
77
  */
57
78
  connect(onEvent, onError) {
58
79
  this.disconnect();
59
- this._state = 'connecting';
80
+ this.attempt = 0;
81
+ this.open(onEvent, onError);
82
+ }
83
+ /** Close the SSE connection, cancel any pending reconnect, and stop receiving events. */
84
+ disconnect() {
85
+ clearTimeout(this.reconnectTimer);
86
+ this.reconnectTimer = undefined;
87
+ this.abortController?.abort();
88
+ this.abortController = undefined;
89
+ this.setState('disconnected');
90
+ }
91
+ open(onEvent, onError) {
92
+ this.setState(this.attempt === 0 ? 'connecting' : 'reconnecting');
60
93
  const controller = new AbortController();
61
94
  this.abortController = controller;
62
95
  (async () => {
96
+ const { idleTimeoutMs } = this.options;
97
+ let idleTimer;
98
+ let idledOut = false;
99
+ const resetIdleTimer = () => {
100
+ if (!idleTimeoutMs)
101
+ return;
102
+ clearTimeout(idleTimer);
103
+ idleTimer = setTimeout(() => {
104
+ idledOut = true;
105
+ controller.abort();
106
+ }, idleTimeoutMs);
107
+ };
108
+ let retry = true;
109
+ let error;
63
110
  try {
111
+ resetIdleTimer();
112
+ const token = typeof this.token === 'function' ? this.token() : this.token;
64
113
  const response = await globalThis.fetch(this.url, {
65
114
  headers: {
66
- 'Authorization': `Bearer ${this.token}`,
115
+ 'Authorization': `Bearer ${token}`,
67
116
  'Accept': 'text/event-stream',
68
117
  'Cache-Control': 'no-cache',
69
118
  },
70
119
  signal: controller.signal,
71
120
  });
72
- // Guard against a concurrent connect() that replaced our controller.
73
- if (this.abortController !== controller)
74
- return;
75
121
  if (!response.ok || !response.body) {
122
+ await response.body?.cancel();
123
+ retry = response.ok || SseConnection.isRetryableStatus(response.status);
76
124
  throw new Error(`SSE connection failed: HTTP ${response.status}`);
77
125
  }
78
- this._state = 'connected';
79
- await this.readStream(response.body, onEvent);
126
+ this.attempt = 0;
127
+ this.setState('connected');
128
+ await this.readStream(response.body, onEvent, resetIdleTimer);
129
+ error = new Error('SSE stream closed by the server');
80
130
  }
81
131
  catch (err) {
82
- if (err.name !== 'AbortError') {
83
- // Only update state if this is still the active connection.
84
- if (this.abortController !== controller)
85
- return;
86
- this._state = 'error';
87
- onError?.(err instanceof Error ? err : new Error(String(err)));
88
- return;
89
- }
132
+ error = idledOut
133
+ ? new Error(`SSE stream received nothing for ${idleTimeoutMs}ms`)
134
+ : err instanceof Error
135
+ ? err
136
+ : new Error(String(err));
90
137
  }
91
- if (this.abortController === controller) {
92
- this._state = 'disconnected';
138
+ finally {
139
+ clearTimeout(idleTimer);
93
140
  }
141
+ // disconnect() or a newer connect() replaced this attempt, so it ends silently.
142
+ if (this.abortController !== controller)
143
+ return;
144
+ onError?.(error);
145
+ if (this.abortController !== controller)
146
+ return;
147
+ if (!retry || !this.options.reconnect) {
148
+ this.abortController = undefined;
149
+ this.setState('error');
150
+ return;
151
+ }
152
+ this.setState('reconnecting');
153
+ this.reconnectTimer = setTimeout(() => {
154
+ this.reconnectTimer = undefined;
155
+ this.open(onEvent, onError);
156
+ }, this.nextReconnectDelay());
94
157
  })();
95
158
  }
96
- /** Close the SSE connection and stop receiving events. */
97
- disconnect() {
98
- this.abortController?.abort();
99
- this.abortController = undefined;
100
- this._state = 'disconnected';
159
+ /**
160
+ * Exponential backoff with jitter, so clients that lost the same server do not all retry at
161
+ * the same instant when it comes back.
162
+ */
163
+ nextReconnectDelay() {
164
+ const { minReconnectDelayMs, maxReconnectDelayMs } = this.options;
165
+ const base = Math.min(maxReconnectDelayMs, minReconnectDelayMs * 2 ** this.attempt);
166
+ this.attempt++;
167
+ return base / 2 + Math.random() * (base / 2);
168
+ }
169
+ /**
170
+ * 401 is retryable because the token is re-read on every attempt and may have been refreshed
171
+ * in the meantime. Other client errors (403, 404, ...) will not change on their own.
172
+ */
173
+ static isRetryableStatus(status) {
174
+ return status >= 500 || status === 401 || status === 408 || status === 429;
175
+ }
176
+ setState(state) {
177
+ if (this._state === state)
178
+ return;
179
+ this._state = state;
180
+ this.emit('state', state);
101
181
  }
102
182
  /** Parse the raw `ReadableStream` body into SSE frames, calling `onEvent` for each complete frame. */
103
- async readStream(body, onEvent) {
183
+ async readStream(body, onEvent, onChunk) {
104
184
  const reader = body.getReader();
105
185
  const decoder = new TextDecoder();
106
186
  let buffer = '';
@@ -111,6 +191,7 @@ class SseConnection {
111
191
  const { done, value } = await reader.read();
112
192
  if (done)
113
193
  break;
194
+ onChunk();
114
195
  buffer += decoder.decode(value, { stream: true });
115
196
  const lines = buffer.split('\n');
116
197
  buffer = lines.pop() ?? '';