@dev-crew-berlin/enter-js-utils 0.97.5 → 0.98.10

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.
@@ -23,6 +23,12 @@ export type FetchOptions = {
23
23
  revalidate: false | 0 | number;
24
24
  };
25
25
  };
26
+ export type SubscribeOptions = {
27
+ signal?: AbortSignal;
28
+ onReset?: () => void;
29
+ onConnectionChange?: (state: 'connected' | 'reconnecting') => void;
30
+ initialLastEventId?: string;
31
+ };
26
32
  export default class APIBase {
27
33
  private credentials;
28
34
  private isLoggedIn;
@@ -36,8 +42,8 @@ export default class APIBase {
36
42
  onLogout: (reason?: string) => void;
37
43
  });
38
44
  private static fetchResult;
39
- private buildHeaders;
40
- private fetchResponse;
45
+ protected buildHeaders(extraHeaders?: Record<string, string>): Record<string, string>;
46
+ protected fetchResponse(endpoint: string, init: RequestInit): Promise<APIResult<Response>>;
41
47
  /**
42
48
  * @category Auth
43
49
  */
@@ -56,7 +62,22 @@ export default class APIBase {
56
62
  expires: Date;
57
63
  }>>;
58
64
  private fetchFromEnter;
59
- protected stream<T>(endpoint: string, options?: FetchOptions): AsyncGenerator<T>;
65
+ /**
66
+ * Self-healing SSE stream with automatic reconnect, heartbeat watchdog, and
67
+ * cursor-based replay. Yields domain events as parsed JSON.
68
+ *
69
+ * Reconnect behaviour: when the connection drops (network error, server
70
+ * close, or watchdog timeout) the method sleeps with exponential backoff and
71
+ * jitter, then opens a new request. The last received SSE `id` is sent as
72
+ * `Last-Event-ID` so the server can replay missed events. Backoff resets
73
+ * after a connection that stayed alive long enough to be considered healthy.
74
+ *
75
+ * The loop exits without retrying on 401 (session expired — logout is
76
+ * handled by fetchResponse) or when the consumer breaks / the external
77
+ * signal fires.
78
+ */
79
+ protected stream<T>(endpoint: string, options?: SubscribeOptions): AsyncGenerator<T>;
80
+ private readSseEvents;
60
81
  protected get<Path extends keyof paths>(endpoint: Path, options: FetchOptions): Promise<APIResult<ResponseType<Path, 'get'>>>;
61
82
  protected post<Path extends keyof paths>(endpoint: Path, data: RequestBody<Path, 'post'>, options: FetchOptions): Promise<APIResult<ResponseType<Path, 'post'>>>;
62
83
  protected put<Path extends keyof paths>(endpoint: Path, data: RequestBody<Path, 'put'>, options: FetchOptions): Promise<APIResult<ResponseType<Path, 'put'>>>;
@@ -1,6 +1,21 @@
1
1
  import jws from 'jws';
2
2
  import { EventSourceParserStream } from 'eventsource-parser/stream';
3
3
  import { failure, success } from '../lib';
4
+ const BACKOFF_INITIAL_MS = 1_000;
5
+ const BACKOFF_MAX_MS = 30_000;
6
+ const BACKOFF_RESET_AFTER_MS = 10_000;
7
+ const WATCHDOG_TIMEOUT_MS = 45_000;
8
+ function sleep(ms, signal) {
9
+ return new Promise(resolve => {
10
+ const timer = setTimeout(resolve, ms);
11
+ signal.addEventListener('abort', () => {
12
+ clearTimeout(timer);
13
+ resolve();
14
+ }, {
15
+ once: true
16
+ });
17
+ });
18
+ }
4
19
  export default class APIBase {
5
20
  constructor(args) {
6
21
  this.credentials = args.credentials;
@@ -135,21 +150,121 @@ export default class APIBase {
135
150
  const json = await res.data.json();
136
151
  return success(json);
137
152
  }
153
+
154
+ /**
155
+ * Self-healing SSE stream with automatic reconnect, heartbeat watchdog, and
156
+ * cursor-based replay. Yields domain events as parsed JSON.
157
+ *
158
+ * Reconnect behaviour: when the connection drops (network error, server
159
+ * close, or watchdog timeout) the method sleeps with exponential backoff and
160
+ * jitter, then opens a new request. The last received SSE `id` is sent as
161
+ * `Last-Event-ID` so the server can replay missed events. Backoff resets
162
+ * after a connection that stayed alive long enough to be considered healthy.
163
+ *
164
+ * The loop exits without retrying on 401 (session expired — logout is
165
+ * handled by fetchResponse) or when the consumer breaks / the external
166
+ * signal fires.
167
+ */
138
168
  async *stream(endpoint, options = {}) {
139
- const res = await this.fetchResponse(endpoint, {
140
- method: 'GET',
141
- headers: this.buildHeaders({
142
- Accept: 'text/event-stream'
143
- }),
144
- ...options
145
- });
146
- if (!res.success) {
147
- throw new Error(res.error[0]?.message ?? 'Unknown error');
148
- }
149
- if (!res.data.body) {
150
- throw new Error('Response body is null');
169
+ const {
170
+ signal: externalSignal,
171
+ onReset,
172
+ onConnectionChange
173
+ } = options;
174
+
175
+ // outerAbort is the single switch that stops the whole reconnect loop.
176
+ // It is wired to the external signal and also fired in the finally block
177
+ // so that the sleep() in the backoff path is always interrupted on exit.
178
+ const outerAbort = new AbortController();
179
+ if (externalSignal?.aborted) return;
180
+ const onExternalAbort = () => outerAbort.abort();
181
+ externalSignal?.addEventListener('abort', onExternalAbort);
182
+ let lastEventId = options.initialLastEventId ?? null;
183
+ let backoffMs = BACKOFF_INITIAL_MS;
184
+ let isFirstConnect = true;
185
+ try {
186
+ while (!outerAbort.signal.aborted) {
187
+ // Fresh abort controller per attempt so the watchdog or a read error
188
+ // on one attempt cannot bleed into the next.
189
+ const attemptAbort = new AbortController();
190
+ const propagateAbort = () => attemptAbort.abort();
191
+ outerAbort.signal.addEventListener('abort', propagateAbort);
192
+ const hadCursor = lastEventId !== null;
193
+ const headers = {
194
+ Accept: 'text/event-stream'
195
+ };
196
+ // Sending Last-Event-ID lets the server replay events we missed while
197
+ // disconnected. If the cursor is unknown or too old, the server replies
198
+ // with event:reset instead of replaying.
199
+ if (hadCursor) headers['Last-Event-ID'] = lastEventId;
200
+
201
+ // connectedAt stays 0 when the attempt never reached a live stream,
202
+ // which prevents a spurious backoff reset on pure network failures.
203
+ let connectedAt = 0;
204
+ try {
205
+ const res = await this.fetchResponse(endpoint, {
206
+ method: 'GET',
207
+ headers: this.buildHeaders(headers),
208
+ signal: attemptAbort.signal
209
+ });
210
+ if (!res.success) {
211
+ const err = res.error[0];
212
+ // 401 means the session is gone — fetchResponse already called
213
+ // logout(), so stop retrying rather than hammering the server.
214
+ if (err?.type === 'http-error' && err.statusCode === 401) return;
215
+ // Any other failure (network error, 5xx, …) falls through to backoff.
216
+ } else if (res.data.body) {
217
+ connectedAt = Date.now();
218
+ onConnectionChange?.('connected');
219
+ // On a reconnect with no cursor we cannot know what events were
220
+ // missed, so tell the consumer to refetch their full state.
221
+ if (!isFirstConnect && !hadCursor) onReset?.();
222
+ isFirstConnect = false;
223
+ for await (const {
224
+ data,
225
+ id
226
+ } of this.readSseEvents(res.data.body, attemptAbort, onReset)) {
227
+ if (id) lastEventId = id;
228
+ yield data;
229
+ }
230
+ }
231
+ } catch {
232
+ // Covers fetch throwing (network error) and the stream aborting
233
+ // mid-read — both fall through to the backoff sleep below.
234
+ } finally {
235
+ outerAbort.signal.removeEventListener('abort', propagateAbort);
236
+ attemptAbort.abort();
237
+ }
238
+ if (outerAbort.signal.aborted) break;
239
+
240
+ // Reset backoff when the previous connection was healthy long enough;
241
+ // otherwise double it (with jitter) up to the cap.
242
+ const lived = connectedAt > 0 ? Date.now() - connectedAt : 0;
243
+ if (lived >= BACKOFF_RESET_AFTER_MS) backoffMs = BACKOFF_INITIAL_MS;
244
+ onConnectionChange?.('reconnecting');
245
+ await sleep(backoffMs, outerAbort.signal);
246
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS) * (0.5 + Math.random());
247
+ }
248
+ } finally {
249
+ // Aborting here also unblocks any in-progress sleep() call.
250
+ outerAbort.abort();
251
+ externalSignal?.removeEventListener('abort', onExternalAbort);
151
252
  }
152
- const reader = res.data.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();
253
+ }
254
+
255
+ // Reads parsed SSE events from a single response body until the stream
256
+ // closes or the attemptAbort signal fires. Manages the heartbeat watchdog:
257
+ // if no message arrives within WATCHDOG_TIMEOUT_MS the connection is
258
+ // considered half-open (common on mobile networks) and is aborted so the
259
+ // outer loop can reconnect.
260
+ async *readSseEvents(body, attemptAbort, onReset) {
261
+ const reader = body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();
262
+ let watchdogTimer = null;
263
+ const resetWatchdog = () => {
264
+ if (watchdogTimer) clearTimeout(watchdogTimer);
265
+ watchdogTimer = setTimeout(() => attemptAbort.abort(), WATCHDOG_TIMEOUT_MS);
266
+ };
267
+ resetWatchdog();
153
268
  try {
154
269
  while (true) {
155
270
  const {
@@ -157,15 +272,31 @@ export default class APIBase {
157
272
  value: event
158
273
  } = await reader.read();
159
274
  if (done) break;
160
- if (event.data === '[DONE]') return;
275
+
276
+ // Any message — including heartbeats — proves the connection is alive.
277
+ resetWatchdog();
278
+
279
+ // Heartbeats are only keepalives; never yield them to the consumer.
280
+ if (event.event === 'heartbeat') continue;
281
+ // The server sends reset when the requested cursor is unknown or too
282
+ // old. The consumer should refetch full state; we do not yield it.
283
+ if (event.event === 'reset') {
284
+ onReset?.();
285
+ continue;
286
+ }
161
287
  try {
162
- yield JSON.parse(event.data);
288
+ yield {
289
+ data: JSON.parse(event.data),
290
+ id: event.id
291
+ };
163
292
  } catch {
164
293
  console.warn('[SSE] Failed to parse event data:', event.data);
165
294
  }
166
295
  }
167
296
  } finally {
297
+ if (watchdogTimer) clearTimeout(watchdogTimer);
168
298
  reader.releaseLock();
299
+ attemptAbort.abort();
169
300
  }
170
301
  }
171
302
  async get(endpoint, options) {