@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.
- package/dist/api-client/api-base.d.ts +24 -3
- package/dist/api-client/api-base.js +146 -15
- package/dist/api-client/api-client.test.js +748 -0
- package/dist/api-client/index.d.ts +11 -5
- package/dist/api-client/index.js +25 -5
- package/dist/generated/api-schema.d.ts +618 -267
- package/dist/models/attendee.d.ts +4 -1
- package/dist/models/attendee.js +4 -0
- package/package.json +7 -7
- package/dist/ui/button.stories.d.ts +0 -16
- package/dist/ui/checkin-count-indicator.stories.d.ts +0 -14
- package/dist/ui/checkin-progress-bar.stories.d.ts +0 -12
- package/dist/ui/companion-info.stories.d.ts +0 -7
- package/dist/ui/enter-logo.stories.d.ts +0 -7
- package/dist/ui/form-elements/input.stories.d.ts +0 -8
- package/dist/ui/form-elements/label.stories.d.ts +0 -6
- package/dist/ui/form-elements/search-input.stories.d.ts +0 -7
- package/dist/ui/form-elements/segmented-control.stories.d.ts +0 -6
- package/dist/ui/form-elements/select.stories.d.ts +0 -8
- package/dist/ui/guest-card.stories.d.ts +0 -14
- package/dist/ui/icons/add-guest-icon.stories.d.ts +0 -7
- package/dist/ui/icons/caret-icon.stories.d.ts +0 -7
- package/dist/ui/icons/filter-icon.stories.d.ts +0 -7
- package/dist/ui/icons/search-icon.stories.d.ts +0 -7
- package/dist/ui/icons/settings-icon.stories.d.ts +0 -7
- package/dist/ui/icons/sort-list-icon.stories.d.ts +0 -7
- package/dist/ui/tag.stories.d.ts +0 -6
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|