@meowpanel/api 0.13.0 → 0.14.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.
@@ -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() ?? '';