@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.
- package/esm/mod.d.ts +1 -1
- package/esm/mod.d.ts.map +1 -1
- package/esm/src/resources/servers/backups.d.ts +7 -7
- package/esm/src/resources/servers/backups.d.ts.map +1 -1
- package/esm/src/resources/servers/backups.js +8 -8
- package/esm/src/resources/servers/connection.d.ts +34 -7
- package/esm/src/resources/servers/connection.d.ts.map +1 -1
- package/esm/src/resources/servers/connection.js +109 -36
- package/esm/src/resources/servers/server.js +1 -1
- package/esm/src/sse.d.ts +63 -11
- package/esm/src/sse.d.ts.map +1 -1
- package/esm/src/sse.js +112 -31
- package/package.json +1 -1
- package/script/mod.d.ts +1 -1
- package/script/mod.d.ts.map +1 -1
- package/script/src/resources/servers/backups.d.ts +7 -7
- package/script/src/resources/servers/backups.d.ts.map +1 -1
- package/script/src/resources/servers/backups.js +8 -8
- package/script/src/resources/servers/connection.d.ts +34 -7
- package/script/src/resources/servers/connection.d.ts.map +1 -1
- package/script/src/resources/servers/connection.js +109 -36
- package/script/src/resources/servers/server.js +1 -1
- package/script/src/sse.d.ts +63 -11
- package/script/src/sse.d.ts.map +1 -1
- package/script/src/sse.js +112 -31
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
62
|
+
constructor(serverId, nodeUrl, getToken) {
|
|
51
63
|
super();
|
|
52
64
|
this.serverId = serverId;
|
|
53
65
|
this.nodeUrl = nodeUrl;
|
|
54
|
-
this.
|
|
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
|
|
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.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
85
|
-
this.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
this.
|
|
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?.
|
|
267
|
+
if (typeof parsed?.id !== 'string')
|
|
217
268
|
return;
|
|
218
269
|
payload = {
|
|
219
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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;
|
package/script/src/sse.d.ts
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
28
|
-
* @param token
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
}
|
package/script/src/sse.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/src/sse.ts"],"names":[],"mappings":"AAAA,
|
|
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
|
|
29
|
-
* @param token
|
|
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
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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.
|
|
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 ${
|
|
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.
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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() ?? '';
|